style_capsule 1.2.0 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1fcbe6c03f9675593d6dca7b3d7d70de2b7d471d2d938993b59877614332ff4
4
- data.tar.gz: 6aa5feb7ff25ef755084e7754f328e4340a0d4dc620e1ea8298badbb0387f254
3
+ metadata.gz: 0dc57ce596c8aec7767199f8c57b82db16a8f96b33ac06cba599febd89afa86a
4
+ data.tar.gz: b4767a7eed2a864357027386452ea6a2da24577be3376388569c8ceb61bff937
5
5
  SHA512:
6
- metadata.gz: f06af1e47282aa0220b5ff28e5e0a197a8d76eaa9f3c00639958ca1f9d672c7ad1dd4f672b9de83cc7c521af6a49b9c9f9c9bd0ce476575211b1fc7a7aeb1da2
7
- data.tar.gz: 3844dd9da6608b1eff6e495e19b92e3f90d4b41df323118f5fa7c6216603d3b33620c80df8aeb13c2e2506e133e3ab6e424c3dfd76feb16dbbc68b37fac72636
6
+ metadata.gz: a666c80dc9ddc70179e9992bc4a1c0c9b58a295e21e0fc64441227529c4b401fdc7c7a703af5e87403ca407d0e482a5b97c73b1cc81d385a021cea2e1186c8d5
7
+ data.tar.gz: 0d8523c1659c5467b6e0a1aa6fc1268f999f669772a2ff22b66b386ec5c6ed19d85a9b9dc2b915f744d691ad2d9ccaa2dd7bc33f5c132a3c7d1fe1df073dcd41
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 1.3.0 (2025-11-26)
4
+
5
+ - Added comprehensive instrumentation via `StyleCapsule::Instrumentation` using ActiveSupport::Notifications
6
+ - Instrumentation events for CSS processing (`style_capsule.css_processor.scope`) with duration and size metrics
7
+ - Instrumentation events for CSS file writing (`style_capsule.css_file_writer.write`) with duration and size metrics
8
+ - Added fallback directory support for CSS file writing when default location is read-only (e.g., Docker containers)
9
+ - Automatic fallback to `/tmp/style_capsule` when primary output directory is not writable
10
+ - Instrumentation events for fallback scenarios (`style_capsule.css_file_writer.fallback`, `style_capsule.css_file_writer.fallback_failure`)
11
+ - Instrumentation events for write failures (`style_capsule.css_file_writer.write_failure`)
12
+ - All instrumentation is zero-overhead when no subscribers are present (only calculates metrics when actively monitored)
13
+ - Improved test coverage reporting and analysis tools
14
+ - Added community guidelines and governance documents
15
+
3
16
  ## 1.2.0 (2025-11-24)
4
17
 
5
18
  - Added `StyleCapsule::ClassRegistry` for Rails-friendly class tracking without ObjectSpace iteration
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # style_capsule
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/style_capsule.svg?v=1.2.0)](https://badge.fury.io/rb/style_capsule) [![Test Status](https://github.com/amkisko/style_capsule.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/style_capsule.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/style_capsule.rb/graph/badge.svg?token=2U6NXJOVVM)](https://codecov.io/gh/amkisko/style_capsule.rb)
3
+ [![Gem Version](https://badge.fury.io/rb/style_capsule.svg?v=1.3.0)](https://badge.fury.io/rb/style_capsule) [![Test Status](https://github.com/amkisko/style_capsule.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/style_capsule.rb/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/amkisko/style_capsule.rb/graph/badge.svg?token=2U6NXJOVVM)](https://codecov.io/gh/amkisko/style_capsule.rb)
4
4
 
5
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
 
@@ -28,6 +28,8 @@ Then run `bundle install`.
28
28
  - **CSS Nesting support** (optional, more performant, requires modern browsers)
29
29
  - **Stylesheet registry** with thread-safe head rendering, namespace support, and compatibility with Propshaft and other asset bundlers
30
30
  - **Multiple cache strategies**: none, time-based, custom proc, and file-based (HTTP caching)
31
+ - **Comprehensive instrumentation** via ActiveSupport::Notifications for monitoring and metrics
32
+ - **Fallback directory support** for read-only filesystems (e.g., Docker containers)
31
33
  - **Security protections**: path traversal protection, input validation, size limits
32
34
 
33
35
  ## Usage
@@ -265,10 +267,13 @@ StyleCapsule::CssFileWriter.configure(
265
267
  output_dir: Rails.root.join("app/assets/builds/capsules"),
266
268
  filename_pattern: ->(component_class, capsule_id) {
267
269
  "capsule-#{capsule_id}.css"
268
- }
270
+ },
271
+ fallback_dir: "/tmp/style_capsule" # Optional, defaults to /tmp/style_capsule
269
272
  )
270
273
  ```
271
274
 
275
+ **Fallback Directory:** In production environments where the app directory is read-only (e.g., Docker containers), StyleCapsule automatically falls back to writing files to `/tmp/style_capsule` when the default location is not writable. When using the fallback directory, the gem gracefully falls back to inline CSS rendering, keeping the UI fully functional.
276
+
272
277
  **Precompilation:**
273
278
 
274
279
  ```bash
@@ -280,6 +285,70 @@ Files are automatically built during `bin/rails assets:precompile`.
280
285
 
281
286
  **Compatibility:** The stylesheet registry works with Propshaft, Sprockets, and other Rails asset bundlers. Static file paths are collected in a process-wide manifest (similar to Propshaft's approach), while inline CSS is stored per-request.
282
287
 
288
+ ## Instrumentation
289
+
290
+ StyleCapsule provides comprehensive instrumentation via `ActiveSupport::Notifications` for monitoring CSS processing and file writing operations. All instrumentation is zero-overhead when no subscribers are present.
291
+
292
+ ### Available Events
293
+
294
+ - `style_capsule.css_processor.scope` - CSS scoping operations with duration and size metrics
295
+ - `style_capsule.css_file_writer.write` - CSS file write operations with duration and size metrics
296
+ - `style_capsule.css_file_writer.fallback` - When fallback directory is used (read-only filesystem)
297
+ - `style_capsule.css_file_writer.fallback_failure` - When both primary and fallback directories fail
298
+ - `style_capsule.css_file_writer.write_failure` - Other write errors
299
+
300
+ ### Example: Monitoring CSS Processing
301
+
302
+ ```ruby
303
+ # config/initializers/style_capsule.rb
304
+ ActiveSupport::Notifications.subscribe("style_capsule.css_processor.scope") do |name, start, finish, id, payload|
305
+ duration_ms = (finish - start) * 1000
306
+ Rails.logger.info "CSS scoped in #{duration_ms.round(2)}ms, input: #{payload[:input_size]} bytes, output: #{payload[:output_size]} bytes"
307
+ end
308
+ ```
309
+
310
+ ### Example: Monitoring File Writes
311
+
312
+ ```ruby
313
+ ActiveSupport::Notifications.subscribe("style_capsule.css_file_writer.write") do |name, start, finish, id, payload|
314
+ duration_ms = (finish - start) * 1000
315
+ StatsD.timing("style_capsule.write.duration", duration_ms)
316
+ StatsD.histogram("style_capsule.write.size", payload[:size])
317
+ end
318
+ ```
319
+
320
+ ### Example: Monitoring Fallback Scenarios
321
+
322
+ ```ruby
323
+ ActiveSupport::Notifications.subscribe("style_capsule.css_file_writer.fallback") do |name, start, finish, id, payload|
324
+ Rails.logger.warn "StyleCapsule fallback used: #{payload[:component_class]} -> #{payload[:fallback_path]}"
325
+ # Exception info available: payload[:exception] and payload[:exception_object]
326
+ StatsD.increment("style_capsule.css_file_writer.fallback", tags: [
327
+ "component:#{payload[:component_class]}",
328
+ "error:#{payload[:exception].first}"
329
+ ])
330
+ end
331
+ ```
332
+
333
+ ### Example: Error Reporting
334
+
335
+ ```ruby
336
+ ActiveSupport::Notifications.subscribe("style_capsule.css_file_writer.fallback_failure") do |name, start, finish, id, payload|
337
+ ActionReporter.notify(
338
+ "StyleCapsule: CSS write failure (both primary and fallback failed)",
339
+ context: {
340
+ component_class: payload[:component_class],
341
+ original_path: payload[:original_path],
342
+ fallback_path: payload[:fallback_path],
343
+ original_exception: payload[:original_exception],
344
+ fallback_exception: payload[:fallback_exception]
345
+ }
346
+ )
347
+ end
348
+ ```
349
+
350
+ For more details, see the [ActiveSupport::Notifications documentation](https://guides.rubyonrails.org/active_support_instrumentation.html).
351
+
283
352
  ## Advanced Usage
284
353
 
285
354
  ### Database-Stored CSS
data/SECURITY.md CHANGED
@@ -1,55 +1,28 @@
1
1
  # SECURITY
2
2
 
3
- ## Supported Versions
3
+ ## Reporting a Vulnerability
4
4
 
5
- | Version | Supported |
6
- | ------- | ------------------ |
7
- | 1.0.x | :white_check_mark: |
8
- | < 1.0 | :x: |
5
+ **Do NOT** open a public GitHub issue for security vulnerabilities.
9
6
 
10
- ## Security Guidelines
7
+ Email security details to: **security@kiskolabs.com**
11
8
 
12
- StyleCapsule provides minimal security protections to assist with automated CSS scoping. The gem does not validate, sanitize, or secure CSS content itself—this is the application's responsibility.
9
+ Include: description, steps to reproduce, potential impact, and suggested fix (if available).
13
10
 
14
- ### What StyleCapsule Protects
11
+ ### Response Timeline
15
12
 
16
- - **Path Traversal**: Filenames are validated when writing CSS files to prevent directory traversal attacks
17
- - **Input Size Limits**: CSS content is limited to 1MB per component to prevent resource exhaustion
18
- - **Scope ID Validation**: Capsule IDs are validated to prevent injection into HTML attributes
13
+ - We will acknowledge receipt of your report
14
+ - We will provide an initial assessment
15
+ - We will keep you informed of our progress and resolution timeline
19
16
 
20
- ### What StyleCapsule Does NOT Control
17
+ ### Disclosure Policy
21
18
 
22
- **Developer Responsibility:**
23
- - **CSS Content**: StyleCapsule does not validate or sanitize CSS content. Malicious CSS (e.g., data exfiltration via `@import`, CSS injection attacks) is not prevented by the gem
24
- - **User Input**: Applications must validate and sanitize user-provided CSS before passing it to StyleCapsule
25
- - **Developer Intent**: The gem trusts that developers provide safe CSS content from trusted sources
19
+ - We will work with you to understand and resolve the issue
20
+ - We will credit you for the discovery (unless you prefer to remain anonymous)
21
+ - We will publish a security advisory after the vulnerability is patched
22
+ - We will coordinate public disclosure with you
26
23
 
27
- **Rails Framework:**
28
- - HTML escaping is handled by Rails' built-in helpers (`content_tag`, etc.)
29
- - Content Security Policy (CSP) must be configured at the application level
30
- - File system permissions and access control are managed by the application
24
+ ## Automation Security
31
25
 
32
- ### Security Best Practices
26
+ * **Context Isolation:** It is strictly forbidden to include production credentials, API keys, or Personally Identifiable Information (PII) in prompts sent to third-party LLMs or automation services.
33
27
 
34
- 1. **Validate User Input**: Never pass untrusted CSS content to StyleCapsule without validation
35
- 2. **Use Content Security Policy**: Configure CSP headers to restrict inline styles and external resources
36
- 3. **Sanitize User-Generated CSS**: If allowing user input, sanitize CSS before processing
37
- 4. **Keep Dependencies Updated**: Use supported Ruby (>= 3.0) and Rails versions with security patches
38
- 5. **Review Generated Files**: Periodically review files in `app/assets/builds/capsules/` if using file-based caching
39
-
40
- ### Reporting a Vulnerability
41
-
42
- If you discover a security vulnerability, please **do not** open a public issue. Instead:
43
-
44
- 1. **Email**: contact@kiskolabs.com
45
- 2. **Subject**: `[SECURITY] style_capsule vulnerability report`
46
- 3. **Include**: Description, steps to reproduce, potential impact, and suggested fix (if any)
47
-
48
- We will acknowledge receipt within 48 hours and provide an initial assessment within 7 days.
49
-
50
- ### Security Updates
51
-
52
- Security updates are released as patch versions (e.g., 1.0.1, 1.0.2) and announced via:
53
- - GitHub Security Advisories
54
- - RubyGems release notes
55
- - CHANGELOG.md
28
+ * **Supply Chain:** All automated dependencies must be verified.
@@ -463,9 +463,9 @@ module StyleCapsule
463
463
  # Use the configured scoping strategy
464
464
  scoped_css = case scoping_strategy
465
465
  when :nesting
466
- CssProcessor.scope_with_nesting(css_content, capsule_id)
466
+ CssProcessor.scope_with_nesting(css_content, capsule_id, component_class: self.class)
467
467
  else # :selector_patching (default)
468
- CssProcessor.scope_selectors(css_content, capsule_id)
468
+ CssProcessor.scope_selectors(css_content, capsule_id, component_class: self.class)
469
469
  end
470
470
 
471
471
  # Cache at class level (one style block per component type/scope/strategy combination)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "digest/sha1"
5
+ require_relative "instrumentation"
5
6
 
6
7
  module StyleCapsule
7
8
  # Writes inline CSS to files for HTTP caching
@@ -10,10 +11,28 @@ module StyleCapsule
10
11
  # Files are written to a configurable output directory and can be precompiled
11
12
  # via Rails asset pipeline.
12
13
  #
14
+ # In production environments where the app directory is read-only (e.g., Docker containers),
15
+ # this class automatically falls back to writing files to /tmp/style_capsule when the
16
+ # default location is not writable. When using the fallback directory, write_css returns
17
+ # nil, causing StylesheetRegistry to fall back to inline CSS (keeping the UI functional).
18
+ #
19
+ # All fallback scenarios are instrumented via ActiveSupport::Notifications following
20
+ # Rails conventions (https://guides.rubyonrails.org/active_support_instrumentation.html):
21
+ # - style_capsule.css_file_writer.fallback: When fallback directory is used successfully
22
+ # - style_capsule.css_file_writer.fallback_failure: When both primary and fallback fail
23
+ # - style_capsule.css_file_writer.write_failure: When other write errors occur
24
+ #
25
+ # All events include exception information in the standard Rails format:
26
+ # - :exception: Array of [class_name, message]
27
+ # - :exception_object: The exception object itself
28
+ #
29
+ # These events can be subscribed to for monitoring, metrics collection, and error reporting.
30
+ #
13
31
  # @example Configuration
14
32
  # StyleCapsule::CssFileWriter.configure(
15
33
  # output_dir: Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR),
16
- # filename_pattern: ->(component_class, capsule_id) { "capsule-#{capsule_id}.css" }
34
+ # filename_pattern: ->(component_class, capsule_id) { "capsule-#{capsule_id}.css" },
35
+ # fallback_dir: "/tmp/style_capsule" # Optional, defaults to /tmp/style_capsule
17
36
  # )
18
37
  #
19
38
  # @example Usage
@@ -22,13 +41,43 @@ module StyleCapsule
22
41
  # component_class: MyComponent,
23
42
  # capsule_id: "abc123"
24
43
  # )
25
- # # => "capsules/capsule-abc123"
44
+ # # => "capsules/capsule-abc123" (or nil if fallback was used)
45
+ #
46
+ # @example Listening to instrumentation events for monitoring
47
+ # ActiveSupport::Notifications.subscribe("style_capsule.css_file_writer.fallback") do |name, start, finish, id, payload|
48
+ # Rails.logger.warn "StyleCapsule fallback: #{payload[:component_class]} -> #{payload[:fallback_path]}"
49
+ # # Exception info available: payload[:exception] and payload[:exception_object]
50
+ # end
51
+ #
52
+ # @example Subscribing for error reporting
53
+ # ActiveSupport::Notifications.subscribe("style_capsule.css_file_writer.fallback_failure") do |name, start, finish, id, payload|
54
+ # ActionReporter.notify(
55
+ # "StyleCapsule: CSS write failure (both primary and fallback failed)",
56
+ # context: {
57
+ # component_class: payload[:component_class],
58
+ # original_path: payload[:original_path],
59
+ # fallback_path: payload[:fallback_path],
60
+ # original_exception: payload[:original_exception],
61
+ # fallback_exception: payload[:fallback_exception]
62
+ # }
63
+ # )
64
+ # end
65
+ #
66
+ # @example Subscribing for metrics collection
67
+ # ActiveSupport::Notifications.subscribe("style_capsule.css_file_writer.fallback") do |name, start, finish, id, payload|
68
+ # StatsD.increment("style_capsule.css_file_writer.fallback", tags: [
69
+ # "component:#{payload[:component_class]}",
70
+ # "error:#{payload[:exception].first}"
71
+ # ])
72
+ # end
26
73
  class CssFileWriter
27
74
  # Default output directory for CSS files (relative to Rails root)
28
75
  DEFAULT_OUTPUT_DIR = "app/assets/builds/capsules"
76
+ # Fallback directory for when default location is read-only (absolute path)
77
+ FALLBACK_OUTPUT_DIR = "/tmp/style_capsule"
29
78
 
30
79
  class << self
31
- attr_accessor :output_dir, :filename_pattern, :enabled
80
+ attr_accessor :output_dir, :filename_pattern, :enabled, :fallback_dir
32
81
 
33
82
  # Configure CSS file writer
34
83
  #
@@ -38,6 +87,8 @@ module StyleCapsule
38
87
  # Receives: (component_class, capsule_id) and should return filename string
39
88
  # Default: `"capsule-#{capsule_id}.css"` (capsule_id is unique and deterministic)
40
89
  # @param enabled [Boolean] Whether file writing is enabled (default: true)
90
+ # @param fallback_dir [String, Pathname, nil] Fallback directory when default location is read-only
91
+ # Default: `StyleCapsule::CssFileWriter::FALLBACK_OUTPUT_DIR` (/tmp/style_capsule)
41
92
  # @example
42
93
  # StyleCapsule::CssFileWriter.configure(
43
94
  # output_dir: Rails.root.join(StyleCapsule::CssFileWriter::DEFAULT_OUTPUT_DIR),
@@ -47,7 +98,7 @@ module StyleCapsule
47
98
  # StyleCapsule::CssFileWriter.configure(
48
99
  # filename_pattern: ->(klass, capsule) { "#{klass.name.underscore}-#{capsule}.css" }
49
100
  # )
50
- def configure(output_dir: nil, filename_pattern: nil, enabled: true)
101
+ def configure(output_dir: nil, filename_pattern: nil, enabled: true, fallback_dir: nil)
51
102
  @enabled = enabled
52
103
 
53
104
  @output_dir = if output_dir
@@ -58,6 +109,12 @@ module StyleCapsule
58
109
  Pathname.new(DEFAULT_OUTPUT_DIR)
59
110
  end
60
111
 
112
+ @fallback_dir = if fallback_dir
113
+ fallback_dir.is_a?(Pathname) ? fallback_dir : Pathname.new(fallback_dir.to_s)
114
+ else
115
+ Pathname.new(FALLBACK_OUTPUT_DIR)
116
+ end
117
+
61
118
  @filename_pattern = filename_pattern || default_filename_pattern
62
119
  end
63
120
 
@@ -66,17 +123,73 @@ module StyleCapsule
66
123
  # @param css_content [String] CSS content to write
67
124
  # @param component_class [Class] Component class that generated the CSS
68
125
  # @param capsule_id [String] Capsule ID for the component
69
- # @return [String, nil] Relative file path (for stylesheet_link_tag) or nil if disabled
126
+ # @return [String, nil] Relative file path (for stylesheet_link_tag) or nil if disabled/failed
70
127
  def write_css(css_content:, component_class:, capsule_id:)
71
128
  return nil unless enabled?
72
129
 
73
- ensure_output_directory
74
-
75
130
  filename = generate_filename(component_class, capsule_id)
76
131
  file_path = output_directory.join(filename)
132
+ used_fallback = false
77
133
 
78
- # Write CSS to file with explicit UTF-8 encoding
79
- File.write(file_path, css_content, encoding: "UTF-8")
134
+ begin
135
+ ensure_output_directory
136
+ # Write CSS to file with explicit UTF-8 encoding
137
+ Instrumentation.instrument_file_write(
138
+ component_class: component_class,
139
+ capsule_id: capsule_id,
140
+ file_path: file_path.to_s,
141
+ size: css_content.bytesize
142
+ ) do
143
+ File.write(file_path, css_content, encoding: "UTF-8")
144
+ end
145
+ rescue Errno::EACCES, Errno::EROFS => e
146
+ # Permission denied or read-only filesystem - try fallback directory
147
+ fallback_path = fallback_directory.join(filename)
148
+
149
+ begin
150
+ ensure_fallback_directory
151
+ File.write(fallback_path, css_content, encoding: "UTF-8")
152
+ used_fallback = true
153
+ file_path = fallback_path
154
+
155
+ # Instrument the fallback for visibility
156
+ Instrumentation.instrument_fallback(
157
+ component_class: component_class,
158
+ capsule_id: capsule_id,
159
+ original_path: output_directory.join(filename).to_s,
160
+ fallback_path: fallback_path.to_s,
161
+ exception: [e.class.name, e.message],
162
+ exception_object: e
163
+ )
164
+ rescue => fallback_error
165
+ # Even fallback failed - instrument and return nil (will fall back to inline CSS)
166
+ Instrumentation.instrument_fallback_failure(
167
+ component_class: component_class,
168
+ capsule_id: capsule_id,
169
+ original_path: output_directory.join(filename).to_s,
170
+ fallback_path: fallback_path.to_s,
171
+ original_exception: [e.class.name, e.message],
172
+ original_exception_object: e,
173
+ fallback_exception: [fallback_error.class.name, fallback_error.message],
174
+ fallback_exception_object: fallback_error
175
+ )
176
+ return nil
177
+ end
178
+ rescue => e
179
+ # Other errors - instrument and return nil (will fall back to inline CSS)
180
+ Instrumentation.instrument_write_failure(
181
+ component_class: component_class,
182
+ capsule_id: capsule_id,
183
+ file_path: file_path.to_s,
184
+ exception: [e.class.name, e.message],
185
+ exception_object: e
186
+ )
187
+ return nil
188
+ end
189
+
190
+ # If we used fallback directory, return nil (can't serve via asset pipeline)
191
+ # This will cause StylesheetRegistry to fall back to inline CSS
192
+ return nil if used_fallback
80
193
 
81
194
  # Return relative path for stylesheet_link_tag
82
195
  # Path should be relative to app/assets
@@ -135,6 +248,16 @@ module StyleCapsule
135
248
  FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
136
249
  end
137
250
 
251
+ # Ensure fallback directory exists
252
+ #
253
+ # @return [void]
254
+ def ensure_fallback_directory
255
+ return unless enabled?
256
+
257
+ dir = fallback_directory
258
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
259
+ end
260
+
138
261
  # Clear all generated CSS files
139
262
  #
140
263
  # @return [void]
@@ -171,6 +294,11 @@ module StyleCapsule
171
294
  end
172
295
  end
173
296
 
297
+ # Get fallback directory (with default)
298
+ def fallback_directory
299
+ @fallback_dir ||= Pathname.new(FALLBACK_OUTPUT_DIR)
300
+ end
301
+
174
302
  # Generate filename using pattern
175
303
  #
176
304
  # @param component_class [Class] Component class
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "instrumentation"
4
+
3
5
  module StyleCapsule
4
6
  # Shared CSS processing logic for scoping selectors with attribute selectors
5
7
  #
@@ -35,9 +37,10 @@ module StyleCapsule
35
37
  #
36
38
  # @param css_string [String] Original CSS content
37
39
  # @param capsule_id [String] The capsule ID to use in attribute selector
40
+ # @param component_class [Class, String, nil] Optional component class for instrumentation
38
41
  # @return [String] CSS with scoped selectors
39
42
  # @raise [ArgumentError] If CSS content exceeds maximum size or capsule_id is invalid
40
- def self.scope_selectors(css_string, capsule_id)
43
+ def self.scope_selectors(css_string, capsule_id, component_class: nil)
41
44
  return css_string if css_string.nil? || css_string.strip.empty?
42
45
 
43
46
  # Validate CSS size to prevent DoS attacks
@@ -48,61 +51,69 @@ module StyleCapsule
48
51
  # Validate capsule_id
49
52
  validate_capsule_id!(capsule_id)
50
53
 
51
- css = css_string.dup
52
- capsule_attr = %([data-capsule="#{capsule_id}"])
53
-
54
- # Strip CSS comments to avoid interference with selector matching
55
- # Simple approach: remove /* ... */ comments (including multi-line)
56
- css_without_comments = strip_comments(css)
57
-
58
- # Process CSS rule by rule
59
- # Match: selector(s) { ... }
60
- # Pattern: (start or closing brace) + (whitespace) + (selector text) + (opening brace)
61
- # Note: Uses non-greedy quantifier ([^{}@]+?) to minimize backtracking
62
- # MAX_CSS_SIZE limit (1MB) mitigates ReDoS risk from malicious input
63
- css_without_comments.gsub!(/(^|\})(\s*)([^{}@]+?)(\{)/m) do |_|
64
- prefix = Regexp.last_match(1) # Previous closing brace or start
65
- whitespace = Regexp.last_match(2) # Whitespace between rules
66
- selectors_raw = Regexp.last_match(3) # The selector group
67
- selectors = selectors_raw.strip # Stripped for processing
68
- opening_brace = Regexp.last_match(4) # The opening brace
69
-
70
- # Skip at-rules (@media, @keyframes, etc.) - they should not be scoped at top level
71
- next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.start_with?("@")
72
-
73
- # Skip if already scoped (avoid double-scoping)
74
- next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors_raw.include?("[data-capsule=")
75
-
76
- # Skip empty selectors
77
- next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.empty?
78
-
79
- # Split selectors by comma and scope each one
80
- scoped_selectors = selectors.split(",").map do |selector|
81
- selector = selector.strip
82
- next selector if selector.empty?
83
-
84
- # Handle special component-scoped selectors (:host, :host-context)
85
- if selector.start_with?(":host")
86
- selector = selector
87
- .gsub(/^:host-context\(([^)]+)\)/, "#{capsule_attr} \\1")
88
- .gsub(/^:host\(([^)]+)\)/, "#{capsule_attr}\\1")
89
- .gsub(/^:host\b/, capsule_attr)
90
- selector
91
- else
92
- # Add capsule attribute with space before selector for descendant matching
93
- # This ensures styles apply to elements inside the scoped wrapper
94
- "#{capsule_attr} #{selector}"
95
- end
96
- end.compact.join(", ")
97
-
98
- "#{prefix}#{whitespace}#{scoped_selectors}#{opening_brace}"
54
+ # Instrument CSS processing with timing and size metrics
55
+ Instrumentation.instrument_css_processing(
56
+ strategy: :selector_patching,
57
+ component_class: component_class || "Unknown",
58
+ capsule_id: capsule_id,
59
+ css_content: css_string
60
+ ) do
61
+ css = css_string.dup
62
+ capsule_attr = %([data-capsule="#{capsule_id}"])
63
+
64
+ # Strip CSS comments to avoid interference with selector matching
65
+ # Simple approach: remove /* ... */ comments (including multi-line)
66
+ css_without_comments = strip_comments(css)
67
+
68
+ # Process CSS rule by rule
69
+ # Match: selector(s) { ... }
70
+ # Pattern: (start or closing brace) + (whitespace) + (selector text) + (opening brace)
71
+ # Note: Uses non-greedy quantifier ([^{}@]+?) to minimize backtracking
72
+ # MAX_CSS_SIZE limit (1MB) mitigates ReDoS risk from malicious input
73
+ css_without_comments.gsub!(/(^|\})(\s*)([^{}@]+?)(\{)/m) do |_|
74
+ prefix = Regexp.last_match(1) # Previous closing brace or start
75
+ whitespace = Regexp.last_match(2) # Whitespace between rules
76
+ selectors_raw = Regexp.last_match(3) # The selector group
77
+ selectors = selectors_raw.strip # Stripped for processing
78
+ opening_brace = Regexp.last_match(4) # The opening brace
79
+
80
+ # Skip at-rules (@media, @keyframes, etc.) - they should not be scoped at top level
81
+ next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.start_with?("@")
82
+
83
+ # Skip if already scoped (avoid double-scoping)
84
+ next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors_raw.include?("[data-capsule=")
85
+
86
+ # Skip empty selectors
87
+ next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.empty?
88
+
89
+ # Split selectors by comma and scope each one
90
+ scoped_selectors = selectors.split(",").map do |selector|
91
+ selector = selector.strip
92
+ next selector if selector.empty?
93
+
94
+ # Handle special component-scoped selectors (:host, :host-context)
95
+ if selector.start_with?(":host")
96
+ selector = selector
97
+ .gsub(/^:host-context\(([^)]+)\)/, "#{capsule_attr} \\1")
98
+ .gsub(/^:host\(([^)]+)\)/, "#{capsule_attr}\\1")
99
+ .gsub(/^:host\b/, capsule_attr)
100
+ selector
101
+ else
102
+ # Add capsule attribute with space before selector for descendant matching
103
+ # This ensures styles apply to elements inside the scoped wrapper
104
+ "#{capsule_attr} #{selector}"
105
+ end
106
+ end.compact.join(", ")
107
+
108
+ "#{prefix}#{whitespace}#{scoped_selectors}#{opening_brace}"
109
+ end
110
+
111
+ # Restore comments in their original positions
112
+ # Since we stripped comments, we need to put them back
113
+ # For simplicity, we'll just return the processed CSS without comments
114
+ # (comments are typically removed in production CSS anyway)
115
+ css_without_comments
99
116
  end
100
-
101
- # Restore comments in their original positions
102
- # Since we stripped comments, we need to put them back
103
- # For simplicity, we'll just return the processed CSS without comments
104
- # (comments are typically removed in production CSS anyway)
105
- css_without_comments
106
117
  end
107
118
 
108
119
  # Scope CSS using CSS nesting (wraps entire CSS in [data-capsule] { ... })
@@ -122,9 +133,10 @@ module StyleCapsule
122
133
  #
123
134
  # @param css_string [String] Original CSS content
124
135
  # @param capsule_id [String] The capsule ID to use in attribute selector
136
+ # @param component_class [Class, String, nil] Optional component class for instrumentation
125
137
  # @return [String] CSS wrapped in nesting selector
126
138
  # @raise [ArgumentError] If CSS content exceeds maximum size or capsule_id is invalid
127
- def self.scope_with_nesting(css_string, capsule_id)
139
+ def self.scope_with_nesting(css_string, capsule_id, component_class: nil)
128
140
  return css_string if css_string.nil? || css_string.strip.empty?
129
141
 
130
142
  # Validate CSS size to prevent DoS attacks
@@ -135,10 +147,18 @@ module StyleCapsule
135
147
  # Validate capsule_id
136
148
  validate_capsule_id!(capsule_id)
137
149
 
138
- # Simply wrap the entire CSS in the capsule attribute selector
139
- # No parsing or transformation needed - much more performant
140
- capsule_attr = %([data-capsule="#{capsule_id}"])
141
- "#{capsule_attr} {\n#{css_string}\n}"
150
+ # Instrument CSS processing with timing and size metrics
151
+ Instrumentation.instrument_css_processing(
152
+ strategy: :nesting,
153
+ component_class: component_class || "Unknown",
154
+ capsule_id: capsule_id,
155
+ css_content: css_string
156
+ ) do
157
+ # Simply wrap the entire CSS in the capsule attribute selector
158
+ # No parsing or transformation needed - much more performant
159
+ capsule_attr = %([data-capsule="#{capsule_id}"])
160
+ "#{capsule_attr} {\n#{css_string}\n}"
161
+ end
142
162
  end
143
163
 
144
164
  # Strip CSS comments (/* ... */) from the string
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StyleCapsule
4
+ # Centralized instrumentation module for StyleCapsule
5
+ #
6
+ # Provides efficient, non-blocking instrumentation using ActiveSupport::Notifications.
7
+ # Metrics (time, size) are only calculated when subscribers are present, ensuring
8
+ # zero performance impact when instrumentation is not being used.
9
+ #
10
+ # All events follow Rails naming convention: style_capsule.{module}.{event}
11
+ #
12
+ # @example Subscribing to CSS processing events
13
+ # ActiveSupport::Notifications.subscribe("style_capsule.css_processor.scope") do |name, start, finish, id, payload|
14
+ # duration_ms = (finish - start) * 1000
15
+ # input_size = payload[:input_size]
16
+ # Rails.logger.info "CSS scoped in #{duration_ms}ms, input: #{input_size} bytes"
17
+ # end
18
+ #
19
+ # @example Using Event object for more details
20
+ # ActiveSupport::Notifications.subscribe("style_capsule.css_processor.scope") do |event|
21
+ # Rails.logger.info "CSS scoped in #{event.duration}ms, input: #{event.payload[:input_size]} bytes"
22
+ # end
23
+ #
24
+ # @example Subscribing to file write events
25
+ # ActiveSupport::Notifications.subscribe("style_capsule.css_file_writer.write") do |name, start, finish, id, payload|
26
+ # StatsD.timing("style_capsule.write.duration", (finish - start) * 1000)
27
+ # StatsD.histogram("style_capsule.write.size", payload[:size])
28
+ # end
29
+ module Instrumentation
30
+ # Check if ActiveSupport::Notifications is available
31
+ #
32
+ # @return [Boolean]
33
+ def self.available?
34
+ defined?(ActiveSupport::Notifications)
35
+ end
36
+
37
+ # Instrument an operation with automatic timing and size metrics
38
+ #
39
+ # This method is highly efficient:
40
+ # - Only measures time if subscribers exist (ActiveSupport::Notifications is optimized for this)
41
+ # - Only calculates sizes if subscribers exist (lazy evaluation)
42
+ # - Uses monotonic time for accurate measurements
43
+ #
44
+ # @param event_name [String] Event name following Rails convention (e.g., "style_capsule.css_processor.scope")
45
+ # @param payload [Hash] Additional payload data
46
+ # @yield The operation to instrument
47
+ # @yieldreturn [Object] Result of the operation (used for output size calculation if needed)
48
+ # @return [Object] Return value of the block
49
+ # @example Instrumenting CSS processing
50
+ # result = Instrumentation.instrument(
51
+ # "style_capsule.css_processor.scope",
52
+ # component_class: component_class.name,
53
+ # capsule_id: capsule_id,
54
+ # strategy: :selector_patching,
55
+ # input_size: css_content.bytesize
56
+ # ) do
57
+ # CssProcessor.scope_selectors(css_content, capsule_id)
58
+ # end
59
+ def self.instrument(event_name, payload = {}, &block)
60
+ return yield unless available?
61
+
62
+ # Check if there are any subscribers (ActiveSupport optimizes this check)
63
+ # If no subscribers, just execute the block without any overhead
64
+ unless ActiveSupport::Notifications.notifier.listening?(event_name)
65
+ return yield
66
+ end
67
+
68
+ # Calculate input size if not provided but css_content/input is in payload
69
+ unless payload[:input_size]
70
+ if payload[:css_content]
71
+ payload[:input_size] = payload[:css_content].bytesize
72
+ elsif payload[:input]
73
+ payload[:input_size] = payload[:input].bytesize
74
+ end
75
+ end
76
+
77
+ # Use ActiveSupport::Notifications.instrument which automatically:
78
+ # - Measures duration using monotonic time (available in event.duration)
79
+ # - Only does work if subscribers exist (zero overhead if no subscribers)
80
+ # Note: output_size is not included in payload (subscribers can calculate from result if needed)
81
+ result = nil
82
+ ActiveSupport::Notifications.instrument(event_name, payload) do
83
+ result = yield
84
+ result
85
+ end
86
+
87
+ result
88
+ end
89
+
90
+ # Instrument an event without a block (for events that don't have duration)
91
+ #
92
+ # @param event_name [String] Event name
93
+ # @param payload [Hash] Payload data
94
+ # @return [void]
95
+ # @example Instrumenting a fallback event
96
+ # Instrumentation.notify(
97
+ # "style_capsule.css_file_writer.fallback",
98
+ # component_class: component_class.name,
99
+ # original_path: original_path,
100
+ # fallback_path: fallback_path,
101
+ # exception: [e.class.name, e.message],
102
+ # exception_object: e
103
+ # )
104
+ def self.notify(event_name, payload = {})
105
+ return unless available?
106
+
107
+ # Only notify if subscribers exist
108
+ return unless ActiveSupport::Notifications.notifier.listening?(event_name)
109
+
110
+ ActiveSupport::Notifications.instrument(event_name, payload)
111
+ end
112
+
113
+ # Instrument CSS processing operations
114
+ #
115
+ # @param strategy [Symbol] Scoping strategy (:selector_patching or :nesting)
116
+ # @param component_class [Class, String] Component class
117
+ # @param capsule_id [String] Capsule ID
118
+ # @param css_content [String] CSS content (for size calculation)
119
+ # @yield The CSS processing operation
120
+ # @return [String] Processed CSS
121
+ def self.instrument_css_processing(strategy:, component_class:, capsule_id:, css_content:, &block)
122
+ component_name = component_class.is_a?(Class) ? component_class.name : component_class.to_s
123
+ input_size = css_content.bytesize
124
+
125
+ instrument(
126
+ "style_capsule.css_processor.scope",
127
+ strategy: strategy,
128
+ component_class: component_name,
129
+ capsule_id: capsule_id,
130
+ input_size: input_size
131
+ ) do
132
+ result = yield
133
+ # Output size will be calculated by subscribers if needed
134
+ # They can access it via: result.bytesize
135
+ result
136
+ end
137
+ end
138
+
139
+ # Instrument CSS file write operations
140
+ #
141
+ # @param component_class [Class] Component class
142
+ # @param capsule_id [String] Capsule ID
143
+ # @param file_path [String] File path
144
+ # @param size [Integer] CSS content size in bytes
145
+ # @yield The file write operation
146
+ # @return [Object] Return value of the block
147
+ def self.instrument_file_write(component_class:, capsule_id:, file_path:, size:, &block)
148
+ instrument(
149
+ "style_capsule.css_file_writer.write",
150
+ component_class: component_class.name,
151
+ capsule_id: capsule_id,
152
+ file_path: file_path,
153
+ size: size
154
+ ) do
155
+ yield
156
+ end
157
+ end
158
+
159
+ # Instrument fallback to temporary directory
160
+ #
161
+ # @param component_class [Class] Component class
162
+ # @param capsule_id [String] Capsule ID
163
+ # @param original_path [String] Original file path that failed
164
+ # @param fallback_path [String] Fallback file path used
165
+ # @param exception [Array<String>] Exception [class_name, message]
166
+ # @param exception_object [Exception] The exception object
167
+ # @return [void]
168
+ def self.instrument_fallback(component_class:, capsule_id:, original_path:, fallback_path:, exception:, exception_object:)
169
+ notify(
170
+ "style_capsule.css_file_writer.fallback",
171
+ component_class: component_class.name,
172
+ capsule_id: capsule_id,
173
+ original_path: original_path,
174
+ fallback_path: fallback_path,
175
+ exception: exception,
176
+ exception_object: exception_object
177
+ )
178
+ end
179
+
180
+ # Instrument fallback failure (both original and fallback failed)
181
+ #
182
+ # @param component_class [Class] Component class
183
+ # @param capsule_id [String] Capsule ID
184
+ # @param original_path [String] Original file path that failed
185
+ # @param fallback_path [String] Fallback file path that also failed
186
+ # @param original_exception [Array<String>] Original exception [class_name, message]
187
+ # @param original_exception_object [Exception] Original exception object
188
+ # @param fallback_exception [Array<String>] Fallback exception [class_name, message]
189
+ # @param fallback_exception_object [Exception] Fallback exception object
190
+ # @return [void]
191
+ def self.instrument_fallback_failure(component_class:, capsule_id:, original_path:, fallback_path:, original_exception:, original_exception_object:, fallback_exception:, fallback_exception_object:)
192
+ notify(
193
+ "style_capsule.css_file_writer.fallback_failure",
194
+ component_class: component_class.name,
195
+ capsule_id: capsule_id,
196
+ original_path: original_path,
197
+ fallback_path: fallback_path,
198
+ original_exception: original_exception,
199
+ original_exception_object: original_exception_object,
200
+ fallback_exception: fallback_exception,
201
+ fallback_exception_object: fallback_exception_object
202
+ )
203
+ end
204
+
205
+ # Instrument write failure (non-permission errors)
206
+ #
207
+ # @param component_class [Class] Component class
208
+ # @param capsule_id [String] Capsule ID
209
+ # @param file_path [String] File path that failed
210
+ # @param exception [Array<String>] Exception [class_name, message]
211
+ # @param exception_object [Exception] The exception object
212
+ # @return [void]
213
+ def self.instrument_write_failure(component_class:, capsule_id:, file_path:, exception:, exception_object:)
214
+ notify(
215
+ "style_capsule.css_file_writer.write_failure",
216
+ component_class: component_class.name,
217
+ capsule_id: capsule_id,
218
+ file_path: file_path,
219
+ exception: exception,
220
+ exception_object: exception_object
221
+ )
222
+ end
223
+
224
+ # Instrument stylesheet registration
225
+ #
226
+ # @param namespace [Symbol, String] Namespace
227
+ # @param file_path [String, nil] File path (if file-based)
228
+ # @param inline_size [Integer, nil] Inline CSS size in bytes (if inline)
229
+ # @param cache_strategy [Symbol] Cache strategy
230
+ # @yield The registration operation
231
+ # @return [Object] Return value of the block
232
+ def self.instrument_registration(namespace:, file_path: nil, inline_size: nil, cache_strategy: :none, &block)
233
+ payload = {
234
+ namespace: namespace.to_s,
235
+ cache_strategy: cache_strategy
236
+ }
237
+ payload[:file_path] = file_path if file_path
238
+ payload[:inline_size] = inline_size if inline_size
239
+
240
+ instrument(
241
+ "style_capsule.stylesheet_registry.register",
242
+ payload
243
+ ) do
244
+ yield
245
+ end
246
+ end
247
+ end
248
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StyleCapsule
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.0"
5
5
  end
data/lib/style_capsule.rb CHANGED
@@ -89,6 +89,7 @@ end
89
89
  # # Or manually: bin/rails style_capsule:build
90
90
  module StyleCapsule
91
91
  require_relative "style_capsule/version"
92
+ require_relative "style_capsule/instrumentation"
92
93
  require_relative "style_capsule/css_processor"
93
94
  require_relative "style_capsule/css_file_writer"
94
95
  require_relative "style_capsule/stylesheet_registry"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: style_capsule
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -268,6 +268,7 @@ files:
268
268
  - lib/style_capsule/css_file_writer.rb
269
269
  - lib/style_capsule/css_processor.rb
270
270
  - lib/style_capsule/helper.rb
271
+ - lib/style_capsule/instrumentation.rb
271
272
  - lib/style_capsule/phlex_helper.rb
272
273
  - lib/style_capsule/railtie.rb
273
274
  - lib/style_capsule/standalone_helper.rb