style_capsule 1.0.2 → 1.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 +4 -4
- data/CHANGELOG.md +12 -1
- data/README.md +91 -13
- data/lib/style_capsule/component.rb +1 -1
- data/lib/style_capsule/component_builder.rb +131 -0
- data/lib/style_capsule/css_file_writer.rb +9 -3
- data/lib/style_capsule/helper.rb +9 -5
- data/lib/style_capsule/phlex_helper.rb +8 -4
- data/lib/style_capsule/railtie.rb +3 -1
- data/lib/style_capsule/standalone_helper.rb +196 -0
- data/lib/style_capsule/stylesheet_registry.rb +114 -24
- data/lib/style_capsule/version.rb +1 -1
- data/lib/style_capsule/view_component.rb +1 -1
- data/lib/style_capsule/view_component_helper.rb +8 -4
- data/lib/style_capsule.rb +16 -5
- data/lib/tasks/style_capsule.rake +2 -69
- data/sig/style_capsule.rbs +6 -3
- metadata +9 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9ca5bf9bf535558147384d22380ad4b54c6b21f85054e578a84f74d35f1d6296
|
|
4
|
+
data.tar.gz: 93c5a155435df7d39f6a2a739d0e6c0be1b99274bf8ebf658e2aab76f065bda1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 34c0a4456e242d881d0c74ca58a726ad66f953ef2fddb41ba5d38fd98219588b6ed85cd07afa2fe352b34d3c75666496af28b6c2e94bbc8b24edcc65823738b3
|
|
7
|
+
data.tar.gz: 8cac6b0eb300d2030883313d70d69fc16063630bc9dd4efac6ed4396049814187aa83abe56b14d753baabb9afdc3eb3a0c7ce14aba8c49543aa9b7e4ce7ca381
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## 1.1.0 (2025-11-21)
|
|
4
|
+
|
|
5
|
+
- Made Rails dependencies optional: `railties` and `activesupport` moved to development dependencies
|
|
6
|
+
- Core functionality now works without Rails (Sinatra, Hanami, plain Ruby, etc.)
|
|
7
|
+
- Rails integration remains fully supported via Railtie
|
|
8
|
+
- Added `StyleCapsule::StandaloneHelper` for non-Rails frameworks
|
|
9
|
+
- `StylesheetRegistry` now works without `ActiveSupport::CurrentAttributes` using thread-local storage fallback
|
|
10
|
+
- Renamed `stylesheet_registrymap_tags` to `stylesheet_registry_tags` (old name kept as deprecated alias)
|
|
11
|
+
- Extracted CSS building logic from Rake tasks into `StyleCapsule::ComponentBuilder`
|
|
12
|
+
- Fixed XSS vulnerability in `escape_html_attr` by using `CGI.escapeHTML` for proper HTML entity escaping
|
|
13
|
+
- Optimized ActiveSupport require to avoid exception handling overhead in Rails apps
|
|
14
|
+
|
|
3
15
|
## 1.0.2 (2025-11-21)
|
|
4
16
|
|
|
5
17
|
- Fix default output directory for CSS files to app/assets/builds/capsules/
|
|
@@ -26,4 +38,3 @@
|
|
|
26
38
|
- Security features: path traversal protection, CSS size limits (1MB), scope ID validation, filename validation
|
|
27
39
|
- Ruby >= 3.0 requirement
|
|
28
40
|
- Comprehensive test suite with > 93% coverage
|
|
29
|
-
|
data/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# style_capsule
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/rb/style_capsule) [](https://github.com/amkisko/style_capsule.rb/actions/workflows/test.yml) [](https://codecov.io/gh/amkisko/style_capsule.rb)
|
|
4
4
|
|
|
5
|
-
CSS scoping extension for
|
|
5
|
+
CSS scoping extension for Ruby components. Provides attribute-based style encapsulation for Phlex, ViewComponent, and ERB templates to prevent style leakage between components. Works with Rails and can be used standalone in other Ruby frameworks (Sinatra, Hanami, etc.) or plain Ruby scripts. Includes configurable caching strategies for optimal performance.
|
|
6
6
|
|
|
7
7
|
Sponsored by [Kisko Labs](https://www.kiskolabs.com).
|
|
8
8
|
|
|
@@ -161,8 +161,8 @@ Then in your layout:
|
|
|
161
161
|
|
|
162
162
|
```erb
|
|
163
163
|
<head>
|
|
164
|
-
<%=
|
|
165
|
-
<%=
|
|
164
|
+
<%= stylesheet_registry_tags %>
|
|
165
|
+
<%= stylesheet_registry_tags(namespace: :admin) %>
|
|
166
166
|
</head>
|
|
167
167
|
```
|
|
168
168
|
|
|
@@ -170,7 +170,7 @@ Or in Phlex (requires including `StyleCapsule::PhlexHelper`):
|
|
|
170
170
|
|
|
171
171
|
```ruby
|
|
172
172
|
head do
|
|
173
|
-
|
|
173
|
+
stylesheet_registry_tags
|
|
174
174
|
end
|
|
175
175
|
```
|
|
176
176
|
|
|
@@ -205,7 +205,7 @@ def call
|
|
|
205
205
|
end
|
|
206
206
|
```
|
|
207
207
|
|
|
208
|
-
Registered files are rendered via `
|
|
208
|
+
Registered files are rendered via `stylesheet_registry_tags` in your layout, just like inline CSS.
|
|
209
209
|
|
|
210
210
|
## Caching Strategies
|
|
211
211
|
|
|
@@ -332,6 +332,90 @@ end
|
|
|
332
332
|
- Component-scoped selectors: `:host`, `:host(.active)`, `:host-context(.theme-dark)`
|
|
333
333
|
- Media queries: `@media (max-width: 768px) { ... }`
|
|
334
334
|
|
|
335
|
+
## Requirements
|
|
336
|
+
|
|
337
|
+
- Ruby >= 3.0
|
|
338
|
+
- Rails >= 6.0, < 9.0 (optional, for Rails integration)
|
|
339
|
+
- ActiveSupport >= 6.0, < 9.0 (optional, for Rails integration)
|
|
340
|
+
|
|
341
|
+
**Note**: The gem can be used without Rails! See [Non-Rails Support](#non-rails-support) below.
|
|
342
|
+
|
|
343
|
+
## Non-Rails Support
|
|
344
|
+
|
|
345
|
+
StyleCapsule can be used without Rails! The core functionality is framework-agnostic.
|
|
346
|
+
|
|
347
|
+
### Standalone Usage
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
require 'style_capsule'
|
|
351
|
+
|
|
352
|
+
# Direct CSS processing
|
|
353
|
+
css = ".section { color: red; }"
|
|
354
|
+
capsule_id = "abc123"
|
|
355
|
+
scoped = StyleCapsule::CssProcessor.scope_selectors(css, capsule_id)
|
|
356
|
+
# => "[data-capsule=\"abc123\"] .section { color: red; }"
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Phlex Without Rails
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
require 'phlex'
|
|
363
|
+
require 'style_capsule'
|
|
364
|
+
|
|
365
|
+
class MyComponent < Phlex::HTML
|
|
366
|
+
include StyleCapsule::Component
|
|
367
|
+
|
|
368
|
+
def component_styles
|
|
369
|
+
<<~CSS
|
|
370
|
+
.section { color: red; }
|
|
371
|
+
CSS
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def view_template
|
|
375
|
+
div(class: "section") { "Hello" }
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### Sinatra
|
|
381
|
+
|
|
382
|
+
```ruby
|
|
383
|
+
require 'sinatra'
|
|
384
|
+
require 'style_capsule'
|
|
385
|
+
|
|
386
|
+
class MyApp < Sinatra::Base
|
|
387
|
+
helpers StyleCapsule::StandaloneHelper
|
|
388
|
+
|
|
389
|
+
get '/' do
|
|
390
|
+
erb :index
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
```erb
|
|
396
|
+
<!-- views/index.erb -->
|
|
397
|
+
<%= style_capsule do %>
|
|
398
|
+
<style>
|
|
399
|
+
.section { color: red; }
|
|
400
|
+
</style>
|
|
401
|
+
<div class="section">Content</div>
|
|
402
|
+
<% end %>
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### Stylesheet Registry Without Rails
|
|
406
|
+
|
|
407
|
+
The stylesheet registry automatically uses thread-local storage when ActiveSupport is not available:
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
require 'style_capsule'
|
|
411
|
+
|
|
412
|
+
# Works without Rails
|
|
413
|
+
StyleCapsule::StylesheetRegistry.register_inline(".test { color: red; }", namespace: :test)
|
|
414
|
+
stylesheets = StyleCapsule::StylesheetRegistry.request_inline_stylesheets
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
For more details, see [docs/non_rails_support.md](docs/non_rails_support.md).
|
|
418
|
+
|
|
335
419
|
## How It Works
|
|
336
420
|
|
|
337
421
|
1. **Scope ID Generation**: Each component class gets a unique scope ID based on its class name (shared across all instances)
|
|
@@ -339,12 +423,6 @@ end
|
|
|
339
423
|
3. **HTML Wrapping**: Component content is automatically wrapped in a scoped element
|
|
340
424
|
4. **No Class Renaming**: Class names remain unchanged (unlike Shadow DOM)
|
|
341
425
|
|
|
342
|
-
## Requirements
|
|
343
|
-
|
|
344
|
-
- Ruby >= 3.0
|
|
345
|
-
- Rails >= 7.0, < 9.0
|
|
346
|
-
- ActiveSupport >= 7.0, < 9.0
|
|
347
|
-
|
|
348
426
|
## Development
|
|
349
427
|
|
|
350
428
|
```bash
|
|
@@ -395,4 +473,4 @@ For detailed security information, see [SECURITY.md](SECURITY.md).
|
|
|
395
473
|
|
|
396
474
|
## License
|
|
397
475
|
|
|
398
|
-
The gem is available as open source under the terms of the [MIT License](
|
|
476
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module StyleCapsule
|
|
4
|
+
# Builds CSS files from StyleCapsule components
|
|
5
|
+
#
|
|
6
|
+
# This class extracts the logic from the rake task so it can be tested independently.
|
|
7
|
+
# The rake task delegates to this class.
|
|
8
|
+
class ComponentBuilder
|
|
9
|
+
class << self
|
|
10
|
+
# Check if Phlex is available
|
|
11
|
+
# This method can be stubbed in tests to test fallback paths
|
|
12
|
+
def phlex_available?
|
|
13
|
+
!!defined?(Phlex::HTML)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Check if ViewComponent is available
|
|
17
|
+
# This method can be stubbed in tests to test fallback paths
|
|
18
|
+
def view_component_available?
|
|
19
|
+
!!defined?(ViewComponent::Base)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Find all Phlex components that use StyleCapsule
|
|
23
|
+
#
|
|
24
|
+
# @return [Array<Class>] Array of component classes
|
|
25
|
+
def find_phlex_components
|
|
26
|
+
return [] unless phlex_available?
|
|
27
|
+
|
|
28
|
+
components = []
|
|
29
|
+
ObjectSpace.each_object(Class) do |klass|
|
|
30
|
+
if klass < Phlex::HTML && klass.included_modules.include?(StyleCapsule::Component)
|
|
31
|
+
components << klass
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
components
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Find all ViewComponent components that use StyleCapsule
|
|
38
|
+
#
|
|
39
|
+
# @return [Array<Class>] Array of component classes
|
|
40
|
+
def find_view_components
|
|
41
|
+
return [] unless view_component_available?
|
|
42
|
+
|
|
43
|
+
components = []
|
|
44
|
+
begin
|
|
45
|
+
ObjectSpace.each_object(Class) do |klass|
|
|
46
|
+
if klass < ViewComponent::Base && klass.included_modules.include?(StyleCapsule::ViewComponent)
|
|
47
|
+
components << klass
|
|
48
|
+
end
|
|
49
|
+
rescue
|
|
50
|
+
# Skip this class if checking inheritance triggers ViewComponent loading errors
|
|
51
|
+
# (e.g., ViewComponent 2.83.0 has a bug with Gem::Version#to_f)
|
|
52
|
+
next
|
|
53
|
+
end
|
|
54
|
+
rescue
|
|
55
|
+
# ViewComponent may have loading issues (e.g., version compatibility)
|
|
56
|
+
# Silently skip ViewComponent components if there's an error
|
|
57
|
+
# This allows the rake task to continue with Phlex components
|
|
58
|
+
end
|
|
59
|
+
components
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Collect all component classes that use StyleCapsule
|
|
63
|
+
#
|
|
64
|
+
# @return [Array<Class>] Array of component classes
|
|
65
|
+
def collect_components
|
|
66
|
+
find_phlex_components + find_view_components
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Build CSS file for a single component
|
|
70
|
+
#
|
|
71
|
+
# @param component_class [Class] Component class to build
|
|
72
|
+
# @param output_proc [Proc, nil] Optional proc to call with output messages
|
|
73
|
+
# @return [String, nil] Generated file path or nil if skipped
|
|
74
|
+
def build_component(component_class, output_proc: nil)
|
|
75
|
+
return nil unless component_class.inline_cache_strategy == :file
|
|
76
|
+
# Check for class method component_styles (required for file caching)
|
|
77
|
+
return nil unless component_class.respond_to?(:component_styles, false)
|
|
78
|
+
|
|
79
|
+
begin
|
|
80
|
+
# Use class method component_styles for file caching
|
|
81
|
+
css_content = component_class.component_styles
|
|
82
|
+
return nil if css_content.nil? || css_content.to_s.strip.empty?
|
|
83
|
+
|
|
84
|
+
# Create a temporary instance to get capsule
|
|
85
|
+
# Some components might require arguments, so we catch errors
|
|
86
|
+
instance = component_class.new
|
|
87
|
+
capsule_id = instance.component_capsule
|
|
88
|
+
scoped_css = instance.send(:scope_css, css_content)
|
|
89
|
+
|
|
90
|
+
# Write CSS file
|
|
91
|
+
file_path = CssFileWriter.write_css(
|
|
92
|
+
css_content: scoped_css,
|
|
93
|
+
component_class: component_class,
|
|
94
|
+
capsule_id: capsule_id
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
output_proc&.call("Generated: #{file_path}") if file_path
|
|
98
|
+
file_path
|
|
99
|
+
rescue ArgumentError, NoMethodError => e
|
|
100
|
+
# Component requires arguments or has dependencies - skip it
|
|
101
|
+
output_proc&.call("Skipped #{component_class.name}: #{e.message}")
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Build CSS files for all components
|
|
107
|
+
#
|
|
108
|
+
# @param output_proc [Proc, nil] Optional proc to call with output messages
|
|
109
|
+
# @return [Integer] Number of files generated
|
|
110
|
+
def build_all(output_proc: nil)
|
|
111
|
+
require "style_capsule/css_file_writer"
|
|
112
|
+
|
|
113
|
+
# Ensure output directory exists
|
|
114
|
+
CssFileWriter.ensure_output_directory
|
|
115
|
+
|
|
116
|
+
# Collect all component classes that use StyleCapsule
|
|
117
|
+
component_classes = collect_components
|
|
118
|
+
|
|
119
|
+
# Generate CSS files for each component
|
|
120
|
+
generated_count = 0
|
|
121
|
+
component_classes.each do |component_class|
|
|
122
|
+
file_path = build_component(component_class, output_proc: output_proc)
|
|
123
|
+
generated_count += 1 if file_path
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
output_proc&.call("StyleCapsule CSS files built successfully")
|
|
127
|
+
generated_count
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -52,7 +52,7 @@ module StyleCapsule
|
|
|
52
52
|
|
|
53
53
|
@output_dir = if output_dir
|
|
54
54
|
output_dir.is_a?(Pathname) ? output_dir : Pathname.new(output_dir.to_s)
|
|
55
|
-
elsif
|
|
55
|
+
elsif rails_available?
|
|
56
56
|
Rails.root.join(DEFAULT_OUTPUT_DIR)
|
|
57
57
|
else
|
|
58
58
|
Pathname.new(DEFAULT_OUTPUT_DIR)
|
|
@@ -154,11 +154,17 @@ module StyleCapsule
|
|
|
154
154
|
@enabled != false
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
+
# Check if Rails is available
|
|
158
|
+
# This method can be stubbed in tests to test fallback paths
|
|
159
|
+
def rails_available?
|
|
160
|
+
defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
161
|
+
end
|
|
162
|
+
|
|
157
163
|
private
|
|
158
164
|
|
|
159
165
|
# Get output directory (with default)
|
|
160
166
|
def output_directory
|
|
161
|
-
@output_dir ||= if
|
|
167
|
+
@output_dir ||= if rails_available?
|
|
162
168
|
Rails.root.join(DEFAULT_OUTPUT_DIR)
|
|
163
169
|
else
|
|
164
170
|
Pathname.new(DEFAULT_OUTPUT_DIR)
|
|
@@ -206,7 +212,7 @@ module StyleCapsule
|
|
|
206
212
|
|
|
207
213
|
# Get Rails assets root (app/assets)
|
|
208
214
|
def rails_assets_root
|
|
209
|
-
if
|
|
215
|
+
if rails_available?
|
|
210
216
|
Rails.root.join("app/assets")
|
|
211
217
|
else
|
|
212
218
|
Pathname.new("app/assets")
|
data/lib/style_capsule/helper.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "digest/sha1"
|
|
4
|
-
|
|
4
|
+
# ActiveSupport string extensions are conditionally required in lib/style_capsule.rb
|
|
5
5
|
|
|
6
6
|
module StyleCapsule
|
|
7
7
|
# ERB Helper module for use in Rails views
|
|
@@ -148,16 +148,20 @@ module StyleCapsule
|
|
|
148
148
|
StyleCapsule::StylesheetRegistry.register(file_path, namespace: namespace, **options)
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
-
# Render StyleCapsule registered stylesheets
|
|
151
|
+
# Render StyleCapsule registered stylesheets
|
|
152
152
|
#
|
|
153
153
|
# Usage in ERB:
|
|
154
|
-
# <%=
|
|
155
|
-
# <%=
|
|
154
|
+
# <%= stylesheet_registry_tags %>
|
|
155
|
+
# <%= stylesheet_registry_tags(namespace: :admin) %>
|
|
156
156
|
#
|
|
157
157
|
# @param namespace [Symbol, String, nil] Optional namespace to render (nil/blank renders all)
|
|
158
158
|
# @return [String] HTML-safe string with stylesheet tags
|
|
159
|
-
def
|
|
159
|
+
def stylesheet_registry_tags(namespace: nil)
|
|
160
160
|
StyleCapsule::StylesheetRegistry.render_head_stylesheets(self, namespace: namespace)
|
|
161
161
|
end
|
|
162
|
+
|
|
163
|
+
# @deprecated Use {#stylesheet_registry_tags} instead.
|
|
164
|
+
# This method name will be removed in a future version.
|
|
165
|
+
alias_method :stylesheet_registrymap_tags, :stylesheet_registry_tags
|
|
162
166
|
end
|
|
163
167
|
end
|
|
@@ -36,17 +36,17 @@ module StyleCapsule
|
|
|
36
36
|
StyleCapsule::StylesheetRegistry.register(file_path, namespace: namespace, **options)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
# Render StyleCapsule registered stylesheets
|
|
39
|
+
# Render StyleCapsule registered stylesheets
|
|
40
40
|
#
|
|
41
41
|
# Usage in Phlex layouts:
|
|
42
42
|
# head do
|
|
43
|
-
#
|
|
44
|
-
#
|
|
43
|
+
# stylesheet_registry_tags
|
|
44
|
+
# stylesheet_registry_tags(namespace: :admin)
|
|
45
45
|
# end
|
|
46
46
|
#
|
|
47
47
|
# @param namespace [Symbol, String, nil] Optional namespace to render (nil/blank renders all)
|
|
48
48
|
# @return [void] Renders stylesheet tags via raw
|
|
49
|
-
def
|
|
49
|
+
def stylesheet_registry_tags(namespace: nil)
|
|
50
50
|
output = StyleCapsule::StylesheetRegistry.render_head_stylesheets(view_context, namespace: namespace)
|
|
51
51
|
# Phlex's raw() requires the object to be marked as safe
|
|
52
52
|
# Use Phlex's safe() if available, otherwise fall back to html_safe for test doubles
|
|
@@ -62,5 +62,9 @@ module StyleCapsule
|
|
|
62
62
|
# Always return the output string for testing/compatibility
|
|
63
63
|
output_string
|
|
64
64
|
end
|
|
65
|
+
|
|
66
|
+
# @deprecated Use {#stylesheet_registry_tags} instead.
|
|
67
|
+
# This method name will be removed in a future version.
|
|
68
|
+
alias_method :stylesheet_registrymap_tags, :stylesheet_registry_tags
|
|
65
69
|
end
|
|
66
70
|
end
|
|
@@ -36,7 +36,9 @@ module StyleCapsule
|
|
|
36
36
|
ObjectSpace.each_object(Class) do |klass|
|
|
37
37
|
# Skip singleton classes and classes without names (they can cause errors)
|
|
38
38
|
# Singleton classes don't have proper names and can't be safely checked
|
|
39
|
-
|
|
39
|
+
# Use fallback for blank? if ActiveSupport not available
|
|
40
|
+
name = klass.name
|
|
41
|
+
next if name.nil? || (name.respond_to?(:blank?) ? name.blank? : name.to_s.strip.empty?)
|
|
40
42
|
next if klass.singleton_class?
|
|
41
43
|
|
|
42
44
|
# Use method_defined? instead of respond_to? to avoid triggering delegation
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/sha1"
|
|
4
|
+
require "cgi"
|
|
5
|
+
|
|
6
|
+
module StyleCapsule
|
|
7
|
+
# Standalone helper module for use without Rails
|
|
8
|
+
#
|
|
9
|
+
# This module provides basic HTML generation and CSS scoping functionality
|
|
10
|
+
# without requiring Rails ActionView helpers. It can be included in any
|
|
11
|
+
# framework's view context or used directly.
|
|
12
|
+
#
|
|
13
|
+
# @example Usage in Sinatra
|
|
14
|
+
# class MyApp < Sinatra::Base
|
|
15
|
+
# helpers StyleCapsule::StandaloneHelper
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Usage in plain Ruby
|
|
19
|
+
# class MyView
|
|
20
|
+
# include StyleCapsule::StandaloneHelper
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# @example Usage in ERB (non-Rails)
|
|
24
|
+
# # In your ERB template context
|
|
25
|
+
# include StyleCapsule::StandaloneHelper
|
|
26
|
+
# style_capsule do
|
|
27
|
+
# "<style>.section { color: red; }</style><div class='section'>Content</div>"
|
|
28
|
+
# end
|
|
29
|
+
module StandaloneHelper
|
|
30
|
+
# Maximum HTML content size (10MB) to prevent DoS attacks
|
|
31
|
+
MAX_HTML_SIZE = 10_000_000
|
|
32
|
+
|
|
33
|
+
# Generate capsule ID based on caller location for uniqueness
|
|
34
|
+
def generate_capsule_id(css_content)
|
|
35
|
+
# Use caller location + CSS content for uniqueness
|
|
36
|
+
caller_info = caller_locations(1, 1).first
|
|
37
|
+
capsule_key = "#{caller_info.path}:#{caller_info.lineno}:#{css_content}"
|
|
38
|
+
"a#{Digest::SHA1.hexdigest(capsule_key)}"[0, 8]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Scope CSS content and return scoped CSS
|
|
42
|
+
def scope_css(css_content, capsule_id)
|
|
43
|
+
# Use thread-local cache to avoid reprocessing
|
|
44
|
+
cache_key = "style_capsule_#{capsule_id}"
|
|
45
|
+
|
|
46
|
+
if Thread.current[cache_key]
|
|
47
|
+
return Thread.current[cache_key]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
scoped_css = CssProcessor.scope_selectors(css_content, capsule_id)
|
|
51
|
+
Thread.current[cache_key] = scoped_css
|
|
52
|
+
scoped_css
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Generate HTML tag without Rails helpers
|
|
56
|
+
#
|
|
57
|
+
# @param tag [String, Symbol] HTML tag name
|
|
58
|
+
# @param content [String, nil] Tag content (or use block)
|
|
59
|
+
# @param options [Hash] HTML attributes
|
|
60
|
+
# @param block [Proc] Block for tag content
|
|
61
|
+
# @return [String] HTML string
|
|
62
|
+
def content_tag(tag, content = nil, **options, &block)
|
|
63
|
+
tag_name = tag.to_s
|
|
64
|
+
content = capture(&block) if block_given? && content.nil?
|
|
65
|
+
content ||= ""
|
|
66
|
+
|
|
67
|
+
attrs = options.map do |k, v|
|
|
68
|
+
if v.is_a?(Hash)
|
|
69
|
+
# Handle nested attributes like data: { capsule: "abc" }
|
|
70
|
+
v.map { |nk, nv| %(#{k}-#{nk}="#{escape_html_attr(nv)}") }.join(" ")
|
|
71
|
+
else
|
|
72
|
+
%(#{k}="#{escape_html_attr(v)}")
|
|
73
|
+
end
|
|
74
|
+
end.join(" ")
|
|
75
|
+
|
|
76
|
+
attrs = " #{attrs}" unless attrs.empty?
|
|
77
|
+
"<#{tag_name}#{attrs}>#{content}</#{tag_name}>"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Capture block content (simplified version without Rails)
|
|
81
|
+
#
|
|
82
|
+
# @param block [Proc] Block to capture
|
|
83
|
+
# @return [String] Captured content
|
|
84
|
+
def capture(&block)
|
|
85
|
+
return "" unless block_given?
|
|
86
|
+
block.call.to_s
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Mark string as HTML-safe (for compatibility)
|
|
90
|
+
#
|
|
91
|
+
# @param string [String] String to mark as safe
|
|
92
|
+
# @return [String] HTML-safe string
|
|
93
|
+
def html_safe(string)
|
|
94
|
+
# In non-Rails context, just return the string
|
|
95
|
+
# If ActiveSupport is available, use its html_safe
|
|
96
|
+
if string.respond_to?(:html_safe)
|
|
97
|
+
string.html_safe
|
|
98
|
+
else
|
|
99
|
+
string
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Raw string (no HTML escaping)
|
|
104
|
+
#
|
|
105
|
+
# @param string [String] String to return as-is
|
|
106
|
+
# @return [String] Raw string
|
|
107
|
+
def raw(string)
|
|
108
|
+
html_safe(string.to_s)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# ERB helper: automatically wraps content in scoped div and processes CSS
|
|
112
|
+
#
|
|
113
|
+
# @param css_content [String, nil] CSS content (or extract from block)
|
|
114
|
+
# @param capsule_id [String, nil] Optional capsule ID
|
|
115
|
+
# @param content_block [Proc] Block containing HTML content
|
|
116
|
+
# @return [String] HTML with scoped CSS and wrapped content
|
|
117
|
+
def style_capsule(css_content = nil, capsule_id: nil, &content_block)
|
|
118
|
+
html_content = nil
|
|
119
|
+
|
|
120
|
+
# If CSS content is provided as argument, use it
|
|
121
|
+
# Otherwise, extract from content block
|
|
122
|
+
if css_content.nil? && block_given?
|
|
123
|
+
full_content = capture(&content_block)
|
|
124
|
+
|
|
125
|
+
# Validate HTML content size to prevent DoS attacks
|
|
126
|
+
if full_content.bytesize > MAX_HTML_SIZE
|
|
127
|
+
raise ArgumentError, "HTML content exceeds maximum size of #{MAX_HTML_SIZE} bytes (got #{full_content.bytesize} bytes)"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Extract <style> tags from content
|
|
131
|
+
style_match = full_content.match(/<style[^>]*>(.*?)<\/style>/m)
|
|
132
|
+
if style_match
|
|
133
|
+
css_content = style_match[1]
|
|
134
|
+
html_content = full_content.sub(/<style[^>]*>.*?<\/style>/m, "").strip
|
|
135
|
+
else
|
|
136
|
+
css_content = nil
|
|
137
|
+
html_content = full_content
|
|
138
|
+
end
|
|
139
|
+
elsif css_content && block_given?
|
|
140
|
+
html_content = capture(&content_block)
|
|
141
|
+
elsif css_content && !block_given?
|
|
142
|
+
# CSS provided but no content block - just return scoped CSS
|
|
143
|
+
capsule_id ||= generate_capsule_id(css_content)
|
|
144
|
+
scoped_css = scope_css(css_content, capsule_id)
|
|
145
|
+
return content_tag(:style, raw(scoped_css), type: "text/css")
|
|
146
|
+
else
|
|
147
|
+
return ""
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# If no CSS, just return content
|
|
151
|
+
return html_safe(html_content) if css_content.nil? || css_content.to_s.strip.empty?
|
|
152
|
+
|
|
153
|
+
# Use provided capsule_id or generate one
|
|
154
|
+
capsule_id ||= generate_capsule_id(css_content)
|
|
155
|
+
scoped_css = scope_css(css_content, capsule_id)
|
|
156
|
+
|
|
157
|
+
# Render style tag and wrapped content
|
|
158
|
+
style_tag = content_tag(:style, raw(scoped_css), type: "text/css")
|
|
159
|
+
wrapped_content = content_tag(:div, raw(html_content), data: {capsule: capsule_id})
|
|
160
|
+
|
|
161
|
+
html_safe(style_tag + wrapped_content)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Register a stylesheet file for head rendering
|
|
165
|
+
#
|
|
166
|
+
# @param file_path [String] Path to stylesheet
|
|
167
|
+
# @param namespace [Symbol, String, nil] Optional namespace
|
|
168
|
+
# @param options [Hash] Options for stylesheet link tag
|
|
169
|
+
# @return [void]
|
|
170
|
+
def register_stylesheet(file_path, namespace: nil, **options)
|
|
171
|
+
StyleCapsule::StylesheetRegistry.register(file_path, namespace: namespace, **options)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Render StyleCapsule registered stylesheets
|
|
175
|
+
#
|
|
176
|
+
# @param namespace [Symbol, String, nil] Optional namespace to render
|
|
177
|
+
# @return [String] HTML-safe string with stylesheet tags
|
|
178
|
+
def stylesheet_registry_tags(namespace: nil)
|
|
179
|
+
StyleCapsule::StylesheetRegistry.render_head_stylesheets(self, namespace: namespace)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# @deprecated Use {#stylesheet_registry_tags} instead.
|
|
183
|
+
# This method name will be removed in a future version.
|
|
184
|
+
alias_method :stylesheet_registrymap_tags, :stylesheet_registry_tags
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
# Escape HTML attribute value
|
|
189
|
+
#
|
|
190
|
+
# @param value [String] Value to escape
|
|
191
|
+
# @return [String] Escaped value
|
|
192
|
+
def escape_html_attr(value)
|
|
193
|
+
CGI.escapeHTML(value.to_s)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "active_support/current_attributes"
|
|
4
|
-
|
|
5
3
|
module StyleCapsule
|
|
4
|
+
# Helper to determine the parent class for StylesheetRegistry
|
|
5
|
+
# ActiveSupport::CurrentAttributes is optional - if ActiveSupport is loaded,
|
|
6
|
+
# it will be available. Otherwise, we fall back to Object.
|
|
7
|
+
#
|
|
8
|
+
# This is evaluated at class definition time, so it can't be stubbed.
|
|
9
|
+
# For testing fallback paths, use the instance methods that check availability.
|
|
10
|
+
def self.stylesheet_registry_parent_class
|
|
11
|
+
defined?(ActiveSupport::CurrentAttributes) ? ActiveSupport::CurrentAttributes : Object
|
|
12
|
+
end
|
|
13
|
+
|
|
6
14
|
# Hybrid registry for stylesheet files that need to be injected into <head>
|
|
7
15
|
#
|
|
8
16
|
# Uses a process-wide manifest for static file paths (like Propshaft) and request-scoped
|
|
@@ -52,7 +60,7 @@ module StyleCapsule
|
|
|
52
60
|
# body(&block)
|
|
53
61
|
# end
|
|
54
62
|
# end
|
|
55
|
-
class StylesheetRegistry <
|
|
63
|
+
class StylesheetRegistry < StyleCapsule.stylesheet_registry_parent_class
|
|
56
64
|
# Default namespace for backward compatibility
|
|
57
65
|
DEFAULT_NAMESPACE = :default
|
|
58
66
|
|
|
@@ -68,10 +76,74 @@ module StyleCapsule
|
|
|
68
76
|
@last_cleanup_time = nil # rubocop:disable Style/ClassVars
|
|
69
77
|
|
|
70
78
|
# Request-scoped storage for inline CSS only
|
|
71
|
-
attribute
|
|
79
|
+
# Only define attribute if we're inheriting from CurrentAttributes
|
|
80
|
+
if defined?(ActiveSupport::CurrentAttributes) && self < ActiveSupport::CurrentAttributes
|
|
81
|
+
attribute :inline_stylesheets
|
|
82
|
+
end
|
|
72
83
|
|
|
73
84
|
class << self
|
|
74
85
|
attr_reader :manifest, :inline_cache
|
|
86
|
+
|
|
87
|
+
# Get current time (ActiveSupport::Time.current or Time.now fallback)
|
|
88
|
+
def current_time
|
|
89
|
+
if defined?(Time) && Time.respond_to?(:current)
|
|
90
|
+
Time.current
|
|
91
|
+
else
|
|
92
|
+
# rubocop:disable Rails/TimeZone
|
|
93
|
+
# Time.now is intentional fallback for non-Rails usage when Time.current is unavailable
|
|
94
|
+
Time.now
|
|
95
|
+
# rubocop:enable Rails/TimeZone
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Check if we're using ActiveSupport::CurrentAttributes
|
|
100
|
+
# This method can be stubbed in tests to test fallback paths
|
|
101
|
+
def using_current_attributes?
|
|
102
|
+
defined?(ActiveSupport::CurrentAttributes) && self < ActiveSupport::CurrentAttributes
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Get inline stylesheets (thread-local fallback if not using CurrentAttributes)
|
|
106
|
+
def inline_stylesheets
|
|
107
|
+
if using_current_attributes?
|
|
108
|
+
# When using CurrentAttributes, access the instance attribute
|
|
109
|
+
# CurrentAttributes automatically provides access to instance attributes
|
|
110
|
+
inst = instance
|
|
111
|
+
inst&.inline_stylesheets || {}
|
|
112
|
+
else
|
|
113
|
+
Thread.current[:style_capsule_inline_stylesheets] ||= {}
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Set inline stylesheets (thread-local fallback if not using CurrentAttributes)
|
|
118
|
+
def inline_stylesheets=(value)
|
|
119
|
+
if using_current_attributes?
|
|
120
|
+
# When using CurrentAttributes, set via the instance
|
|
121
|
+
inst = instance
|
|
122
|
+
inst.inline_stylesheets = value if inst
|
|
123
|
+
else
|
|
124
|
+
Thread.current[:style_capsule_inline_stylesheets] = value
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Get instance (for CurrentAttributes compatibility)
|
|
129
|
+
def instance
|
|
130
|
+
if using_current_attributes?
|
|
131
|
+
# Call the CurrentAttributes instance method from parent class
|
|
132
|
+
super
|
|
133
|
+
else
|
|
134
|
+
# Return a simple object that responds to inline_stylesheets
|
|
135
|
+
# This is mainly for compatibility with code that might call instance.inline_stylesheets
|
|
136
|
+
registry_class = self
|
|
137
|
+
@_standalone_instance ||= begin
|
|
138
|
+
obj = Object.new
|
|
139
|
+
obj.define_singleton_method(:inline_stylesheets) { registry_class.inline_stylesheets }
|
|
140
|
+
obj.define_singleton_method(:inline_stylesheets=) { |v| registry_class.inline_stylesheets = v }
|
|
141
|
+
obj
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
75
147
|
end
|
|
76
148
|
|
|
77
149
|
# Normalize namespace (nil/blank becomes DEFAULT_NAMESPACE)
|
|
@@ -177,14 +249,14 @@ module StyleCapsule
|
|
|
177
249
|
final_css = cached_css || css_content
|
|
178
250
|
|
|
179
251
|
# Store in request-scoped registry
|
|
180
|
-
registry =
|
|
252
|
+
registry = inline_stylesheets
|
|
181
253
|
registry[ns] ||= []
|
|
182
254
|
registry[ns] << {
|
|
183
255
|
type: :inline,
|
|
184
256
|
css_content: final_css,
|
|
185
257
|
capsule_id: capsule_id
|
|
186
258
|
}
|
|
187
|
-
|
|
259
|
+
self.inline_stylesheets = registry
|
|
188
260
|
|
|
189
261
|
# Cache the CSS if strategy is enabled and not already cached
|
|
190
262
|
if cache_strategy != :none && cache_key && !cached_css && cache_strategy != :file
|
|
@@ -216,7 +288,7 @@ module StyleCapsule
|
|
|
216
288
|
# Check expiration based on strategy
|
|
217
289
|
case cache_strategy
|
|
218
290
|
when :time
|
|
219
|
-
return nil if cache_ttl && cached_entry[:expires_at] &&
|
|
291
|
+
return nil if cache_ttl && cached_entry[:expires_at] && current_time > cached_entry[:expires_at]
|
|
220
292
|
when :proc
|
|
221
293
|
return nil unless cache_proc
|
|
222
294
|
# Proc should validate cache entry
|
|
@@ -242,7 +314,13 @@ module StyleCapsule
|
|
|
242
314
|
|
|
243
315
|
case cache_strategy
|
|
244
316
|
when :time
|
|
245
|
-
|
|
317
|
+
# Handle ActiveSupport::Duration (e.g., 1.hour) or integer seconds
|
|
318
|
+
ttl_seconds = if cache_ttl.respond_to?(:to_i)
|
|
319
|
+
cache_ttl.to_i
|
|
320
|
+
else
|
|
321
|
+
cache_ttl
|
|
322
|
+
end
|
|
323
|
+
expires_at = ttl_seconds ? current_time + ttl_seconds : nil
|
|
246
324
|
when :proc
|
|
247
325
|
if cache_proc
|
|
248
326
|
_key, _should_cache, proc_expires = cache_proc.call(css_content, capsule_id, namespace)
|
|
@@ -252,7 +330,7 @@ module StyleCapsule
|
|
|
252
330
|
|
|
253
331
|
@inline_cache[cache_key] = {
|
|
254
332
|
css_content: css_content,
|
|
255
|
-
cached_at:
|
|
333
|
+
cached_at: current_time,
|
|
256
334
|
expires_at: expires_at
|
|
257
335
|
}
|
|
258
336
|
end
|
|
@@ -287,18 +365,18 @@ module StyleCapsule
|
|
|
287
365
|
def self.cleanup_expired_cache
|
|
288
366
|
return 0 if @inline_cache.empty?
|
|
289
367
|
|
|
290
|
-
|
|
368
|
+
now = current_time
|
|
291
369
|
expired_keys = []
|
|
292
370
|
|
|
293
371
|
@inline_cache.each do |cache_key, entry|
|
|
294
372
|
# Remove entries that have an expires_at time and it's in the past
|
|
295
|
-
if entry[:expires_at] &&
|
|
373
|
+
if entry[:expires_at] && now > entry[:expires_at]
|
|
296
374
|
expired_keys << cache_key
|
|
297
375
|
end
|
|
298
376
|
end
|
|
299
377
|
|
|
300
378
|
expired_keys.each { |key| @inline_cache.delete(key) }
|
|
301
|
-
@last_cleanup_time =
|
|
379
|
+
@last_cleanup_time = now
|
|
302
380
|
|
|
303
381
|
expired_keys.size
|
|
304
382
|
end
|
|
@@ -314,7 +392,7 @@ module StyleCapsule
|
|
|
314
392
|
# Cleanup every 5 minutes (300 seconds) to balance memory usage and performance
|
|
315
393
|
cleanup_interval = 300
|
|
316
394
|
|
|
317
|
-
if @last_cleanup_time.nil? || (
|
|
395
|
+
if @last_cleanup_time.nil? || (current_time - @last_cleanup_time) > cleanup_interval
|
|
318
396
|
cleanup_expired_cache
|
|
319
397
|
end
|
|
320
398
|
end
|
|
@@ -332,7 +410,7 @@ module StyleCapsule
|
|
|
332
410
|
#
|
|
333
411
|
# @return [Hash<Symbol, Array<Hash>>] Hash of namespace => array of inline stylesheet registrations
|
|
334
412
|
def self.request_inline_stylesheets
|
|
335
|
-
|
|
413
|
+
inline_stylesheets
|
|
336
414
|
end
|
|
337
415
|
|
|
338
416
|
# Get all stylesheets (files + inline) for a specific namespace
|
|
@@ -361,12 +439,12 @@ module StyleCapsule
|
|
|
361
439
|
# @return [void]
|
|
362
440
|
def self.clear(namespace: nil)
|
|
363
441
|
if namespace.nil?
|
|
364
|
-
|
|
442
|
+
self.inline_stylesheets = {}
|
|
365
443
|
else
|
|
366
444
|
ns = normalize_namespace(namespace)
|
|
367
|
-
registry =
|
|
445
|
+
registry = inline_stylesheets
|
|
368
446
|
registry.delete(ns)
|
|
369
|
-
|
|
447
|
+
self.inline_stylesheets = registry
|
|
370
448
|
end
|
|
371
449
|
end
|
|
372
450
|
|
|
@@ -411,7 +489,7 @@ module StyleCapsule
|
|
|
411
489
|
end
|
|
412
490
|
|
|
413
491
|
clear # Clear request-scoped inline CSS only
|
|
414
|
-
return ""
|
|
492
|
+
return safe_string("") if all_stylesheets.empty?
|
|
415
493
|
|
|
416
494
|
all_stylesheets.map do |stylesheet|
|
|
417
495
|
if stylesheet[:type] == :inline
|
|
@@ -419,7 +497,7 @@ module StyleCapsule
|
|
|
419
497
|
else
|
|
420
498
|
render_file_stylesheet(stylesheet, view_context)
|
|
421
499
|
end
|
|
422
|
-
end.join("\n").
|
|
500
|
+
end.join("\n").then { |s| safe_string(s) }
|
|
423
501
|
|
|
424
502
|
else
|
|
425
503
|
# Render specific namespace
|
|
@@ -427,7 +505,7 @@ module StyleCapsule
|
|
|
427
505
|
stylesheets = stylesheets_for(namespace: ns).dup
|
|
428
506
|
clear(namespace: ns) # Clear request-scoped inline CSS only
|
|
429
507
|
|
|
430
|
-
return ""
|
|
508
|
+
return safe_string("") if stylesheets.empty?
|
|
431
509
|
|
|
432
510
|
stylesheets.map do |stylesheet|
|
|
433
511
|
if stylesheet[:type] == :inline
|
|
@@ -435,7 +513,7 @@ module StyleCapsule
|
|
|
435
513
|
else
|
|
436
514
|
render_file_stylesheet(stylesheet, view_context)
|
|
437
515
|
end
|
|
438
|
-
end.join("\n").
|
|
516
|
+
end.join("\n").then { |s| safe_string(s) }
|
|
439
517
|
|
|
440
518
|
end
|
|
441
519
|
end
|
|
@@ -473,7 +551,7 @@ module StyleCapsule
|
|
|
473
551
|
# Fallback if no view context
|
|
474
552
|
href = "/assets/#{file_path}.css"
|
|
475
553
|
tag_options = options.map { |k, v| %(#{k}="#{v}") }.join(" ")
|
|
476
|
-
%(<link rel="stylesheet" href="#{href}"#{" #{tag_options}" unless tag_options.empty?}>)
|
|
554
|
+
safe_string(%(<link rel="stylesheet" href="#{href}"#{" #{tag_options}" unless tag_options.empty?}>))
|
|
477
555
|
end
|
|
478
556
|
end
|
|
479
557
|
|
|
@@ -486,9 +564,21 @@ module StyleCapsule
|
|
|
486
564
|
# Construct HTML manually to avoid any HTML escaping issues
|
|
487
565
|
# CSS content should not be HTML-escaped as it's inside a <style> tag
|
|
488
566
|
# Using string interpolation with html_safe ensures CSS is not escaped
|
|
489
|
-
%(<style type="text/css">#{css_content}</style>)
|
|
567
|
+
safe_string(%(<style type="text/css">#{css_content}</style>))
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Make string HTML-safe (compatible with Rails and non-Rails)
|
|
571
|
+
#
|
|
572
|
+
# @param string [String] String to mark as safe
|
|
573
|
+
# @return [String] HTML-safe string
|
|
574
|
+
def self.safe_string(string)
|
|
575
|
+
if string.respond_to?(:html_safe)
|
|
576
|
+
string.html_safe
|
|
577
|
+
else
|
|
578
|
+
string
|
|
579
|
+
end
|
|
490
580
|
end
|
|
491
581
|
|
|
492
|
-
private_class_method :render_file_stylesheet, :render_inline_stylesheet
|
|
582
|
+
private_class_method :render_file_stylesheet, :render_inline_stylesheet, :safe_string
|
|
493
583
|
end
|
|
494
584
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "digest/sha1"
|
|
4
|
-
|
|
4
|
+
# ActiveSupport string extensions are conditionally required in lib/style_capsule.rb
|
|
5
5
|
|
|
6
6
|
module StyleCapsule
|
|
7
7
|
# ViewComponent component concern for encapsulated CSS
|
|
@@ -36,18 +36,22 @@ module StyleCapsule
|
|
|
36
36
|
StyleCapsule::StylesheetRegistry.register(file_path, namespace: namespace, **options)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
# Render StyleCapsule registered stylesheets
|
|
39
|
+
# Render StyleCapsule registered stylesheets
|
|
40
40
|
#
|
|
41
41
|
# Usage in ViewComponent layouts:
|
|
42
42
|
# def call
|
|
43
|
-
# helpers.
|
|
44
|
-
# helpers.
|
|
43
|
+
# helpers.stylesheet_registry_tags
|
|
44
|
+
# helpers.stylesheet_registry_tags(namespace: :admin)
|
|
45
45
|
# end
|
|
46
46
|
#
|
|
47
47
|
# @param namespace [Symbol, String, nil] Optional namespace to render (nil/blank renders all)
|
|
48
48
|
# @return [String] HTML-safe string with stylesheet tags
|
|
49
|
-
def
|
|
49
|
+
def stylesheet_registry_tags(namespace: nil)
|
|
50
50
|
StyleCapsule::StylesheetRegistry.render_head_stylesheets(helpers, namespace: namespace)
|
|
51
51
|
end
|
|
52
|
+
|
|
53
|
+
# @deprecated Use {#stylesheet_registry_tags} instead.
|
|
54
|
+
# This method name will be removed in a future version.
|
|
55
|
+
alias_method :stylesheet_registrymap_tags, :stylesheet_registry_tags
|
|
52
56
|
end
|
|
53
57
|
end
|
data/lib/style_capsule.rb
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "digest/sha1"
|
|
4
|
-
require
|
|
4
|
+
# Conditionally require ActiveSupport string extensions if available
|
|
5
|
+
# For non-Rails usage, these are optional
|
|
6
|
+
# Check first to avoid exception handling overhead in common case (Rails apps)
|
|
7
|
+
unless defined?(ActiveSupport) || String.method_defined?(:html_safe)
|
|
8
|
+
begin
|
|
9
|
+
require "active_support/core_ext/string"
|
|
10
|
+
rescue LoadError
|
|
11
|
+
# ActiveSupport not available - core functionality still works
|
|
12
|
+
end
|
|
13
|
+
end
|
|
5
14
|
|
|
6
15
|
# StyleCapsule provides attribute-based CSS scoping for component encapsulation
|
|
7
16
|
# in Phlex components, ViewComponent components, and ERB templates.
|
|
@@ -55,8 +64,8 @@ require "active_support/core_ext/string"
|
|
|
55
64
|
# <div class="section">Content</div>
|
|
56
65
|
# <% end %>
|
|
57
66
|
#
|
|
58
|
-
# <%=
|
|
59
|
-
# <%=
|
|
67
|
+
# <%= stylesheet_registry_tags %>
|
|
68
|
+
# <%= stylesheet_registry_tags(namespace: :admin) %>
|
|
60
69
|
#
|
|
61
70
|
# @example Namespace Support
|
|
62
71
|
# # Register stylesheets with namespaces
|
|
@@ -64,10 +73,10 @@ require "active_support/core_ext/string"
|
|
|
64
73
|
# StyleCapsule::StylesheetRegistry.register('stylesheets/user/profile', namespace: :user)
|
|
65
74
|
#
|
|
66
75
|
# # Render all namespaces (default)
|
|
67
|
-
# <%=
|
|
76
|
+
# <%= stylesheet_registry_tags %>
|
|
68
77
|
#
|
|
69
78
|
# # Render specific namespace
|
|
70
|
-
# <%=
|
|
79
|
+
# <%= stylesheet_registry_tags(namespace: :admin) %>
|
|
71
80
|
#
|
|
72
81
|
# @example File-Based Caching (HTTP Caching)
|
|
73
82
|
# class MyComponent < ApplicationComponent
|
|
@@ -85,9 +94,11 @@ module StyleCapsule
|
|
|
85
94
|
require_relative "style_capsule/stylesheet_registry"
|
|
86
95
|
require_relative "style_capsule/component_styles_support"
|
|
87
96
|
require_relative "style_capsule/component"
|
|
97
|
+
require_relative "style_capsule/standalone_helper"
|
|
88
98
|
require_relative "style_capsule/helper"
|
|
89
99
|
require_relative "style_capsule/phlex_helper"
|
|
90
100
|
require_relative "style_capsule/view_component"
|
|
91
101
|
require_relative "style_capsule/view_component_helper"
|
|
102
|
+
require_relative "style_capsule/component_builder"
|
|
92
103
|
require_relative "style_capsule/railtie" if defined?(Rails) && defined?(Rails::Railtie)
|
|
93
104
|
end
|
|
@@ -3,76 +3,9 @@
|
|
|
3
3
|
namespace :style_capsule do
|
|
4
4
|
desc "Build StyleCapsule CSS files from components (similar to Tailwind CSS build)"
|
|
5
5
|
task build: :environment do
|
|
6
|
-
require "style_capsule/
|
|
7
|
-
|
|
8
|
-
# Ensure output directory exists
|
|
9
|
-
StyleCapsule::CssFileWriter.ensure_output_directory
|
|
10
|
-
|
|
11
|
-
# Collect all component classes that use StyleCapsule
|
|
12
|
-
component_classes = []
|
|
13
|
-
|
|
14
|
-
# Find Phlex components
|
|
15
|
-
if defined?(Phlex::HTML)
|
|
16
|
-
ObjectSpace.each_object(Class) do |klass|
|
|
17
|
-
if klass < Phlex::HTML && klass.included_modules.include?(StyleCapsule::Component)
|
|
18
|
-
component_classes << klass
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Find ViewComponent components
|
|
24
|
-
# Wrap in begin/rescue to handle ViewComponent loading errors (e.g., version compatibility issues)
|
|
25
|
-
begin
|
|
26
|
-
if defined?(ViewComponent::Base)
|
|
27
|
-
ObjectSpace.each_object(Class) do |klass|
|
|
28
|
-
if klass < ViewComponent::Base && klass.included_modules.include?(StyleCapsule::ViewComponent)
|
|
29
|
-
component_classes << klass
|
|
30
|
-
end
|
|
31
|
-
rescue
|
|
32
|
-
# Skip this class if checking inheritance triggers ViewComponent loading errors
|
|
33
|
-
# (e.g., ViewComponent 2.83.0 has a bug with Gem::Version#to_f)
|
|
34
|
-
next
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
rescue
|
|
38
|
-
# ViewComponent may have loading issues (e.g., version compatibility)
|
|
39
|
-
# Silently skip ViewComponent components if there's an error
|
|
40
|
-
# This allows the rake task to continue with Phlex components
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Generate CSS files for each component
|
|
44
|
-
component_classes.each do |component_class|
|
|
45
|
-
next unless component_class.inline_cache_strategy == :file
|
|
46
|
-
# Check for class method component_styles (required for file caching)
|
|
47
|
-
next unless component_class.respond_to?(:component_styles, false)
|
|
48
|
-
|
|
49
|
-
begin
|
|
50
|
-
# Use class method component_styles for file caching
|
|
51
|
-
css_content = component_class.component_styles
|
|
52
|
-
next if css_content.nil? || css_content.to_s.strip.empty?
|
|
53
|
-
|
|
54
|
-
# Create a temporary instance to get capsule
|
|
55
|
-
# Some components might require arguments, so we catch errors
|
|
56
|
-
instance = component_class.new
|
|
57
|
-
capsule_id = instance.component_capsule
|
|
58
|
-
scoped_css = instance.send(:scope_css, css_content)
|
|
59
|
-
|
|
60
|
-
# Write CSS file
|
|
61
|
-
file_path = StyleCapsule::CssFileWriter.write_css(
|
|
62
|
-
css_content: scoped_css,
|
|
63
|
-
component_class: component_class,
|
|
64
|
-
capsule_id: capsule_id
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
puts "Generated: #{file_path}" if file_path
|
|
68
|
-
rescue ArgumentError, NoMethodError => e
|
|
69
|
-
# Component requires arguments or has dependencies - skip it
|
|
70
|
-
puts "Skipped #{component_class.name}: #{e.message}"
|
|
71
|
-
next
|
|
72
|
-
end
|
|
73
|
-
end
|
|
6
|
+
require "style_capsule/component_builder"
|
|
74
7
|
|
|
75
|
-
|
|
8
|
+
StyleCapsule::ComponentBuilder.build_all(output_proc: ->(msg) { puts msg })
|
|
76
9
|
end
|
|
77
10
|
|
|
78
11
|
desc "Clear StyleCapsule generated CSS files"
|
data/sig/style_capsule.rbs
CHANGED
|
@@ -88,7 +88,8 @@ module StyleCapsule
|
|
|
88
88
|
|
|
89
89
|
module ViewComponentHelper
|
|
90
90
|
def register_stylesheet: (String file_path, ?namespace: Symbol | String | nil, **Hash[untyped, untyped] options) -> void
|
|
91
|
-
def
|
|
91
|
+
def stylesheet_registry_tags: (?namespace: Symbol | String | nil) -> String
|
|
92
|
+
def stylesheet_registrymap_tags: (?namespace: Symbol | String | nil) -> String # deprecated
|
|
92
93
|
end
|
|
93
94
|
|
|
94
95
|
module Helper
|
|
@@ -96,12 +97,14 @@ module StyleCapsule
|
|
|
96
97
|
def scope_css: (String css_content, String capsule_id) -> String
|
|
97
98
|
def style_capsule: (?String css_content, ?capsule_id: String | nil) { () -> String } -> String
|
|
98
99
|
def register_stylesheet: (String file_path, ?namespace: Symbol | String | nil, **Hash[untyped, untyped] options) -> void
|
|
99
|
-
def
|
|
100
|
+
def stylesheet_registry_tags: (?namespace: Symbol | String | nil) -> String
|
|
101
|
+
def stylesheet_registrymap_tags: (?namespace: Symbol | String | nil) -> String # deprecated
|
|
100
102
|
end
|
|
101
103
|
|
|
102
104
|
module PhlexHelper
|
|
103
105
|
def register_stylesheet: (String file_path, ?namespace: Symbol | String | nil, **Hash[untyped, untyped] options) -> void
|
|
104
|
-
def
|
|
106
|
+
def stylesheet_registry_tags: (?namespace: Symbol | String | nil) -> void
|
|
107
|
+
def stylesheet_registrymap_tags: (?namespace: Symbol | String | nil) -> void # deprecated
|
|
105
108
|
end
|
|
106
109
|
|
|
107
110
|
class Railtie < Rails::Railtie
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: style_capsule
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrei Makarov
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: activesupport
|
|
@@ -20,7 +19,7 @@ dependencies:
|
|
|
20
19
|
- - "<"
|
|
21
20
|
- !ruby/object:Gem::Version
|
|
22
21
|
version: '9.0'
|
|
23
|
-
type: :
|
|
22
|
+
type: :development
|
|
24
23
|
prerelease: false
|
|
25
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
26
25
|
requirements:
|
|
@@ -40,7 +39,7 @@ dependencies:
|
|
|
40
39
|
- - "<"
|
|
41
40
|
- !ruby/object:Gem::Version
|
|
42
41
|
version: '9.0'
|
|
43
|
-
type: :
|
|
42
|
+
type: :development
|
|
44
43
|
prerelease: false
|
|
45
44
|
version_requirements: !ruby/object:Gem::Requirement
|
|
46
45
|
requirements:
|
|
@@ -249,7 +248,8 @@ dependencies:
|
|
|
249
248
|
description: Provides component-scoped CSS encapsulation using [data-capsule] attributes
|
|
250
249
|
for Phlex components, ViewComponent components, and ERB templates. Styles are automatically
|
|
251
250
|
scoped to prevent leakage between components. Inspired by component-based CSS approaches
|
|
252
|
-
like Angular's view encapsulation and CSS modules.
|
|
251
|
+
like Angular's view encapsulation and CSS modules. Works with Rails and can be used
|
|
252
|
+
standalone in other Ruby frameworks (Sinatra, Hanami, etc.) or plain Ruby scripts.
|
|
253
253
|
email:
|
|
254
254
|
- contact@kiskolabs.com
|
|
255
255
|
executables: []
|
|
@@ -262,12 +262,14 @@ files:
|
|
|
262
262
|
- SECURITY.md
|
|
263
263
|
- lib/style_capsule.rb
|
|
264
264
|
- lib/style_capsule/component.rb
|
|
265
|
+
- lib/style_capsule/component_builder.rb
|
|
265
266
|
- lib/style_capsule/component_styles_support.rb
|
|
266
267
|
- lib/style_capsule/css_file_writer.rb
|
|
267
268
|
- lib/style_capsule/css_processor.rb
|
|
268
269
|
- lib/style_capsule/helper.rb
|
|
269
270
|
- lib/style_capsule/phlex_helper.rb
|
|
270
271
|
- lib/style_capsule/railtie.rb
|
|
272
|
+
- lib/style_capsule/standalone_helper.rb
|
|
271
273
|
- lib/style_capsule/stylesheet_registry.rb
|
|
272
274
|
- lib/style_capsule/version.rb
|
|
273
275
|
- lib/style_capsule/view_component.rb
|
|
@@ -283,7 +285,6 @@ metadata:
|
|
|
283
285
|
bug_tracker_uri: https://github.com/amkisko/style_capsule.rb/issues
|
|
284
286
|
documentation_uri: https://github.com/amkisko/style_capsule.rb#readme
|
|
285
287
|
rubygems_mfa_required: 'true'
|
|
286
|
-
post_install_message:
|
|
287
288
|
rdoc_options: []
|
|
288
289
|
require_paths:
|
|
289
290
|
- lib
|
|
@@ -298,8 +299,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
298
299
|
- !ruby/object:Gem::Version
|
|
299
300
|
version: '0'
|
|
300
301
|
requirements: []
|
|
301
|
-
rubygems_version: 3.
|
|
302
|
-
signing_key:
|
|
302
|
+
rubygems_version: 3.6.9
|
|
303
303
|
specification_version: 4
|
|
304
304
|
summary: Attribute-based CSS scoping for Phlex, ViewComponent, and ERB templates.
|
|
305
305
|
test_files: []
|