style_capsule 2.0.2 → 2.0.3

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: afc951f02fe29cd319b521b363049d3dba5e607099216e1782f48b4578f06f4b
4
- data.tar.gz: 85916b390e95a3109da5a481f6b193a44309864499b5d3cb4ec4a634960c0ccd
3
+ metadata.gz: 7a6c3fda27b85ee3899cbe434dd5230de0d1c9c4ff6c961deefa9b058fd8f6b3
4
+ data.tar.gz: e70f42c7ee1fc234201107862e1c7dfe9f609a9a55333983101aed4d1ec74ff7
5
5
  SHA512:
6
- metadata.gz: bf300d946cf83d68e40a16862698bd8354f8c51dd927e2fb4e6cb8fd9e3f1d6c6298bb545233132399ea496985e1a1b45b9279bae7b9467663a525ef4893f530
7
- data.tar.gz: 95450d3eeef09959b75dca145f06abfe9f5e59619044d3456e43a1c073adac4e51e1c94e59187ddee72ba823de54c4cc640754d80373fdf1e18316e4b7046bb9
6
+ metadata.gz: 163cd5d8f4f0a324121a866adc94c48f31438cd560ba2b30b2d8f3721b645fe4a339e89308cb38552a96294fd8b01a834936615617ff6889203003f593c4e716
7
+ data.tar.gz: 39ca2e8c56745ddcbeae553b401e3e1b0a4b92409f0d066f0a50a9309bc3efac5369f38e929e6d04f0df991b8abbbe30723602c7f5458ac152a4bdf67ad4dc3a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 2.0.3 (2026-11-03)
4
+
5
+ - Leave `@keyframes` / `@font-face` / `@property` steps unscoped under selector patching
6
+ - Hoist non-nestable at-rules outside the CSS nesting wrapper
7
+ - Clear pending request stylesheets when head injection cannot find `</head>`
8
+ - Replace ActiveSupport `blank?` on core CSS and content-type checks
9
+ - Warn once when `:file` cache strategy falls back to `:none` without class-method `component_styles`
10
+ - Fail closed when release confirmation input reaches EOF; accept case-insensitive `y`
11
+ - Require a dated CHANGELOG heading for the release version before `gem build`
12
+ - Run the test workflow on pull requests targeting `main`
13
+
3
14
  ## 2.0.2 (2026-09-25)
4
15
 
5
16
  - Fix `HeadInjectionMiddleware` dropping pending stylesheets on bufferable chunked HTML
data/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # style_capsule
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/style_capsule.svg?v=2.0.2)](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://app.codecov.io/github/amkisko/style_capsule.rb)
3
+ [![Gem Version](https://badge.fury.io/rb/style_capsule.svg?v=2.0.3)](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://app.codecov.io/github/amkisko/style_capsule.rb)
4
4
 
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.
5
+ CSS scoping for Ruby components. Attribute selectors encapsulate styles for Phlex, ViewComponent, and ERB so rules stay inside the component wrapper. Works with Rails and standalone in other Ruby frameworks (Sinatra, Hanami, and similar) or plain Ruby. Cache strategies: none, time-based, custom proc, and file-based.
6
6
 
7
7
  Design changes go through `rfcs/` (RFC 0001).
8
8
 
@@ -18,15 +18,15 @@ Then run `bundle install`.
18
18
 
19
19
  ## Features
20
20
 
21
- - **Attribute-based CSS scoping** (no class name renaming)
22
- - **Phlex, ViewComponent, and ERB support** with automatic Rails integration
23
- - **Per-component-type scope IDs** (shared across instances)
24
- - **CSS Nesting support** (optional, more performant, requires modern browsers)
25
- - **Stylesheet registry** with thread-safe head rendering, namespace support, and compatibility with Propshaft and other asset bundlers
26
- - **Multiple cache strategies**: none, time-based, custom proc, and file-based (HTTP caching)
27
- - **Comprehensive instrumentation** via ActiveSupport::Notifications for monitoring and metrics
28
- - **Fallback directory support** for read-only filesystems (e.g., Docker containers)
29
- - **Security protections**: path traversal protection, input validation, size limits
21
+ - Attribute-based CSS scoping (class names stay as written)
22
+ - Phlex, ViewComponent, and ERB; Rails integration via the railtie
23
+ - Per-component-type scope IDs (shared across instances)
24
+ - Optional CSS nesting (`:nesting`) for browsers that support it
25
+ - Stylesheet registry for head rendering, namespaces, and asset bundlers such as Propshaft
26
+ - Cache strategies: none, time-based, custom proc, and file-based
27
+ - `ActiveSupport::Notifications` events for CSS processing and file writes
28
+ - Fallback output directory when the primary path is not writable
29
+ - Path traversal checks, input validation, and CSS size limits
30
30
 
31
31
  ## Usage
32
32
 
@@ -103,8 +103,8 @@ StyleCapsule supports two CSS scoping strategies:
103
103
  - Better browser support (all modern browsers)
104
104
  - Output: `[data-capsule="abc123"] .section { color: red; }`
105
105
 
106
- 2. **CSS Nesting (optional)**: Wraps entire CSS in `[data-capsule="..."] { ... }`
107
- - More performant (no CSS parsing needed)
106
+ 2. **CSS Nesting (optional)**: Wraps nestable rules in `[data-capsule="..."] { ... }`
107
+ - Skips per-selector rewrite; hoists `@keyframes` / `@font-face` and similar to top level
108
108
  - Requires CSS nesting support (Chrome 112+, Firefox 117+, Safari 16.5+)
109
109
  - Output: `[data-capsule="abc123"] { .section { color: red; } }`
110
110
 
@@ -145,7 +145,7 @@ MyComponent.clear_css_cache
145
145
 
146
146
  ## Stylesheet Registry
147
147
 
148
- For better performance, register styles for head rendering instead of rendering `<style>` tags in the body. Use the unified `style_capsule` method to configure all settings:
148
+ Register styles for head rendering when the layout should emit links or shared style tags instead of body `<style>` tags. Configure with `style_capsule`:
149
149
 
150
150
  ```ruby
151
151
  class MyComponent < ApplicationComponent
@@ -323,7 +323,7 @@ StyleCapsule::CssFileWriter.configure(
323
323
  )
324
324
  ```
325
325
 
326
- **Fallback Directory:** In production environments where the app directory is read-only (e.g., Docker containers), StyleCapsule automatically falls back to writing files under a per-user directory in `Dir.tmpdir` 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.
326
+ **Fallback Directory:** When the primary output directory is not writable (for example a read-only app tree in a container), writes go under a per-user directory in `Dir.tmpdir`. If that write also fails, rendering uses inline CSS.
327
327
 
328
328
  **Precompilation:**
329
329
 
@@ -338,7 +338,7 @@ Files are automatically built during `bin/rails assets:precompile`.
338
338
 
339
339
  ## Instrumentation
340
340
 
341
- 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.
341
+ Subscribe to `ActiveSupport::Notifications` for CSS processing and file write events. Metric payloads are built only when a subscriber is present.
342
342
 
343
343
  ### Available Events
344
344
 
@@ -469,9 +469,9 @@ For setup examples, API notes, and the relationship between `style_capsule` and
469
469
  ## How It Works
470
470
 
471
471
  1. **Scope ID Generation**: Each component class gets a unique scope ID based on its class name (shared across all instances)
472
- 2. **CSS Rewriting**: CSS selectors are rewritten to include `[data-capsule="..."]` attribute selectors
473
- 3. **HTML Wrapping**: Component content is automatically wrapped in a scoped element
474
- 4. **No Class Renaming**: Class names remain unchanged (unlike Shadow DOM)
472
+ 2. **CSS Rewriting**: CSS selectors gain `[data-capsule="..."]` attribute selectors (or a nesting wrapper)
473
+ 3. **HTML Wrapping**: Component content is wrapped in a scoped element
474
+ 4. **Class names**: Class names in CSS and markup stay as written
475
475
 
476
476
  ## Development
477
477
 
@@ -1,9 +1,13 @@
1
1
  # Audit fixes for style_capsule 2.0.1
2
2
 
3
- Recorded 2026-09-04. Implements the ranked findings from docs/issues/20260904174500_engineering-and-dependency-audit.md. Shipped in product CHANGELOG 2.0.2. json lockfile bump is recorded in docs/dependencies/20260904174500_json-cve-2026-71847.md.
3
+ ## Participants
4
+
5
+ Andrei Makarov (@amkisko)
4
6
 
5
7
  ## Decisions
6
8
 
9
+ Recorded 2026-09-04. Implements the ranked findings from docs/issues/20260904174500_engineering-and-dependency-audit.md. Shipped in product CHANGELOG 2.0.2. json lockfile bump is recorded in docs/dependencies/20260904174500_json-cve-2026-71847.md.
10
+
7
11
  Treat stored CSS as untrusted at the style-tag boundary. CssProcessor.reject_style_element_breakout! matches a case-insensitive style closer and raises ArgumentError. Call it after the size check in scope_selectors and scope_with_nesting, and again in render_inline_stylesheet. Empty CSS still returns early without that check.
8
12
 
9
13
  Allowlist non-Rails fallback link attributes. Emit only media, title, crossorigin, integrity, nonce, type, id, class, and data-* names. Escape values with CGI.escapeHTML. Never emit option href, rel, or event handlers. Keep href as /assets/#{validated_path}.css.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Participants
4
4
 
5
- Andrei Makarov
5
+ Andrei Makarov (@amkisko)
6
6
 
7
7
  ## Decisions
8
8
 
@@ -0,0 +1,19 @@
1
+ ## Participants
2
+
3
+ Andrei Makarov (@amkisko)
4
+
5
+ ## Decisions
6
+
7
+ Sync appraisal lockfiles to style_capsule 2.0.2 after the release bump left gemfiles/*.gemfile.lock on 2.0.1. Change usr/bin/release.rb to run appraisal install so path-gem version changes update those locks before the next push.
8
+
9
+ ## Effects
10
+
11
+ test #37 on main (c41eb95) failed: all three matrix jobs exited bundle with code 16 under frozen install because the gemspec version no longer matched the appraisal locks. quality used Gemfile.lock (already 2.0.2) and passed. After updating the three locks, BUNDLE_FROZEN=true bundle check succeeds for each appraisal gemfile. BUNDLE_GEMFILE=gemfiles/rails8ruby34.gemfile bundle exec polyrun parallel-rspec --workers 5 --merge-failures exited 0.
12
+
13
+ ## Next
14
+
15
+ Push patch/sync-appraisal-locks-2-0-2 and confirm the test workflow on main after merge.
16
+
17
+ ## Source
18
+
19
+ https://github.com/amkisko/style_capsule.rb/actions/runs/36104324632
@@ -1,9 +1,13 @@
1
1
  # Engineering and dependency audit of style_capsule 2.0.1
2
2
 
3
- Recorded 2026-09-04. Library gem. No product UI, database, queue, or worker. Pipeline is CSS and HTML in, processor and helpers, class and thread caches, optional file writer, stylesheet registry, optional Rack head injection, HTML out.
3
+ ## Participants
4
+
5
+ Andrei Makarov (@amkisko)
4
6
 
5
7
  ## Decisions
6
8
 
9
+ Recorded 2026-09-04. Library gem. No product UI, database, queue, or worker. Pipeline is CSS and HTML in, processor and helpers, class and thread caches, optional file writer, stylesheet registry, optional Rack head injection, HTML out.
10
+
7
11
  This pass is an audit only. No code change, no lockfile bump, no workflow edit. Rankings follow engineering-audit order: danger, then certainty, then impact, then fix cost. Dependency-audit ran because the request named both. The published gemspec has no runtime gems. json 2.21.1 is a development lockfile pin from activesupport and rubocop, not a consumer runtime of this gem.
8
12
 
9
13
  ## Effects
@@ -2,7 +2,7 @@ CI quality failed on a trailing blank line in spec/support/junit_formatter.rb. T
2
2
 
3
3
  ## Participants
4
4
 
5
- - amkisko
5
+ Andrei Makarov (@amkisko)
6
6
 
7
7
  ## Decisions
8
8
 
@@ -0,0 +1,40 @@
1
+ # Engineering and prose audit before 2.0.3 publish
2
+
3
+ ## Participants
4
+
5
+ Andrei Makarov (@amkisko)
6
+
7
+ ## Decisions
8
+
9
+ Re-audit main for the unpublished 2.0.3 cut after the 2026-10-28 release-tooling pass. Include CssProcessor keyframes and nesting hoist, pending head clear when `</head>` is missing, ActiveSupport-free blank checks, `:file` cache fallback warning, release confirmation and changelog gates, pull_request CI, and ASCII docs punctuation. Leave tag and RubyGems publish to `make release` after commit.
10
+
11
+ ## Effects
12
+
13
+ Pipeline: component/helper render -> CssProcessor -> StylesheetRegistry / CssFileWriter -> HeadInjectionMiddleware -> HTML. Boundary mode covered Rack body buffering and filesystem writes. Resource and identification modes: no new person identifiers in gem logs; cold-require numbers stay session observations. Queue, worker, learned-system, and physical plant modes skipped (absent).
14
+
15
+ Ranked fixes applied:
16
+
17
+ 1. Skip selector patching inside `@keyframes` / `@font-face` / `@property` (high confidence, observed; prior keyframes example was futile).
18
+ 2. Hoist non-nestable at-rules outside the nesting wrapper (high confidence, observed).
19
+ 3. Clear pending request stylesheets when inject cannot find `</head>` or tags are empty (high confidence, observed).
20
+ 4. Replace `blank?` with nil/empty checks in CssProcessor and HeadInjectionMiddleware (high confidence, observed).
21
+ 5. Warn once when `:file` falls back to `:none` without class-method `component_styles` (high confidence, observed).
22
+ 6. Changelog heading and confirmation specs; CHANGELOG dated 2026-11-03; prose pass on README, gemspec, docs/non_rails_support.md, and builder skip warn punctuation.
23
+
24
+ Deferred:
25
+
26
+ - Class-level CSS cache mutex under threaded hosts.
27
+ - Deduping Phlex / ViewComponent / helper render paths and stylesheet_registry split.
28
+ - `component_styles` double evaluation per render.
29
+ - CssFileWriter positive-path cache revalidation after external delete.
30
+ - Optional max-bytes skip for middleware buffering.
31
+
32
+ ## Next
33
+
34
+ Commit, then `make release`.
35
+
36
+ ## Source
37
+
38
+ - docs/reviews/20261103150931_engineering-prose-release-audit.md
39
+ - CHANGELOG.md section 2.0.3 (2026-11-03)
40
+ - Prior pass: docs/reviews/20261028_engineering-audit-release-preparation.md
@@ -81,5 +81,5 @@ stylesheets = StyleCapsule::StylesheetRegistry.request_inline_stylesheets
81
81
 
82
82
  ## Configuration entry points
83
83
 
84
- - **`style_capsule`** — Preferred class-level DSL for namespace, cache strategy, CSS scoping (`:selector_patching` or `:nesting`), head rendering, and wrapper `tag:`.
85
- - **`stylesheet_registry`** — Older alias that enables head rendering and cache options; new code should prefer `style_capsule` for a single configuration surface.
84
+ - `style_capsule` - Class-level DSL for namespace, cache strategy, CSS scoping (`:selector_patching` or `:nesting`), head rendering, and wrapper `tag:`.
85
+ - `stylesheet_registry` - Older alias for head rendering and cache options; use `style_capsule` in new code.
@@ -0,0 +1,25 @@
1
+ # Engineering audit and release preparation
2
+
3
+ ## Participants
4
+
5
+ Andrei Makarov (@amkisko)
6
+
7
+ ## Decisions
8
+
9
+ - Treat end-of-input at the release confirmation prompt as rejection.
10
+ - Keep the ViewComponent loading-error test isolated from unrelated class ancestry checks.
11
+ - Run tests for pull requests targeting `main`.
12
+
13
+ ## Effects
14
+
15
+ - `make test`, RBS validation, RuboCop, and the sharded suite pass.
16
+ - The 2.0.3 gem builds and passes `Gem::Package#verify`.
17
+ - A local cold require used 0.398 CPU seconds and 33,308,672 bytes peak RSS. These are single-machine observations, not release budgets.
18
+
19
+ ## Next
20
+
21
+ - Review the worktree, commit it, and let the release workflow create the tag and publish the gem.
22
+
23
+ ## Source
24
+
25
+ Iterative engineering audit on 2026-10-28.
@@ -0,0 +1,42 @@
1
+ # Engineering and prose audit before 2.0.3 publish
2
+
3
+ ## Participants
4
+
5
+ Andrei Makarov (@amkisko)
6
+
7
+ ## Decisions
8
+
9
+ Re-audit main for the unpublished 2.0.3 cut. Include member CssProcessor keyframes and nesting, pending head clear, blank checks, file-cache warn, release gates, pull_request CI, and ASCII docs punctuation. Leave tag and RubyGems publish to make release after commit.
10
+
11
+ ## Effects
12
+
13
+ Pipeline: render -> CssProcessor -> registry / file writer -> head inject -> HTML. Boundary mode covered Rack buffering and disk writes. Resource and identification modes: no new person identifiers in gem logs. Queue, worker, learned-system, and physical plant modes skipped (absent).
14
+
15
+ Ranked fixes applied:
16
+
17
+ 1. Unscoped `@keyframes` / `@font-face` / `@property` steps under selector patching (high confidence, observed).
18
+ 2. Hoist non-nestable at-rules outside nesting wrapper (high confidence, observed).
19
+ 3. Clear pending request stylesheets when `</head>` is missing (high confidence, observed).
20
+ 4. Nil/empty checks instead of ActiveSupport `blank?` on core paths (high confidence, observed).
21
+ 5. Warn once on illegal `:file` cache fallback (high confidence, observed).
22
+ 6. Changelog heading and confirmation specs; CHANGELOG dated 2026-11-03.
23
+ 7. ASCII and marketing-tone pass on README, CHANGELOG, gemspec, and docs/non_rails_support.md.
24
+
25
+ Deferred (documented in the issue): class CSS cache mutex, helper/component dedupe, double `component_styles` eval, file-path cache revalidation, middleware max-bytes.
26
+
27
+ Checks observed in this session:
28
+
29
+ - bundle exec rubocop on touched Ruby files: 11 files, no offenses.
30
+ - bundle exec rbs validate: ok.
31
+ - bundle exec rspec on css_processor, stylesheet_registry, head_injection_middleware, component, view_component, release_version_check: 249 examples, 0 failures.
32
+ - bundle exec polyrun parallel-rspec --workers 5 --merge-failures: exit 0; coverage/merged-summary.txt 97.00% lines (1101 / 1135) across 19 files.
33
+ - gem build style_capsule.gemspec and Gem::Package#verify for 2.0.3: ok.
34
+
35
+ ## Next
36
+
37
+ Commit, then make release.
38
+
39
+ ## Source
40
+
41
+ - docs/issues/20261103150931_engineering-prose-release-audit.md
42
+ - CHANGELOG.md section 2.0.3 (2026-11-03)
@@ -168,7 +168,7 @@ module StyleCapsule
168
168
 
169
169
  # File caching is only allowed for class method component_styles
170
170
  if cache_strategy == :file && !file_caching_allowed?
171
- # Fall back to :none strategy if file caching requested but not allowed
171
+ warn_file_cache_fallback(self.class)
172
172
  cache_strategy = :none
173
173
  cache_ttl = nil
174
174
  cache_proc = nil
@@ -113,7 +113,7 @@ module StyleCapsule
113
113
  file_path
114
114
  rescue ArgumentError, NoMethodError => e
115
115
  # Component requires arguments or has dependencies - skip it (common for ViewComponent with required kwargs)
116
- warn "[style_capsule] Skipped #{component_class.name} in style_capsule:build — #{e.class}: #{e.message}"
116
+ warn "[style_capsule] Skipped #{component_class.name} in style_capsule:build - #{e.class}: #{e.message}"
117
117
  output_proc&.call("Skipped #{component_class.name}: #{e.message}")
118
118
  nil
119
119
  end
@@ -48,6 +48,16 @@ module StyleCapsule
48
48
 
49
49
  private
50
50
 
51
+ def warn_file_cache_fallback(component_class)
52
+ return if component_class.instance_variable_get(:@_style_capsule_file_cache_warned)
53
+
54
+ component_class.instance_variable_set(:@_style_capsule_file_cache_warned, true)
55
+ label = component_class.name
56
+ label = component_class.inspect if label.nil? || label.empty?
57
+ warn "[StyleCapsule] :file cache strategy requires class-method component_styles; " \
58
+ "falling back to :none for #{label}"
59
+ end
60
+
51
61
  # Check if component defines instance method styles
52
62
  #
53
63
  # @return [Boolean]
@@ -7,15 +7,25 @@ module StyleCapsule
7
7
  #
8
8
  # Supports two scoping strategies:
9
9
  # 1. Selector patching (default): Adds [data-capsule="..."] prefix to each selector
10
- # - Better browser support (all modern browsers)
10
+ # - Wider browser support (all modern browsers)
11
11
  # - Requires CSS parsing and transformation
12
- # 2. CSS nesting (optional): Wraps entire CSS in [data-capsule="..."] { ... }
13
- # - More performant (no parsing needed)
12
+ # 2. CSS nesting (optional): Wraps style rules in [data-capsule="..."] { ... }
13
+ # - Avoids per-selector rewrite
14
14
  # - Requires CSS nesting support (Chrome 112+, Firefox 117+, Safari 16.5+)
15
+ # - Hoists non-nestable at-rules (@keyframes, @font-face, @import, ...) to top level
16
+ # rubocop:disable Metrics/ModuleLength -- processor owns patching, nesting hoist, and validation
15
17
  module CssProcessor
16
18
  # Maximum CSS content size (1MB) to prevent DoS attacks
17
19
  MAX_CSS_SIZE = 1_000_000
18
20
 
21
+ # At-rules whose inner selectors must not receive attribute scoping
22
+ NON_SCOPING_AT_RULES = %w[keyframes font-face property].freeze
23
+
24
+ # At-rules that must stay at the top level under CSS nesting
25
+ HOISTABLE_AT_RULES = %w[
26
+ keyframes font-face import charset namespace property
27
+ ].freeze
28
+
19
29
  # Rewrite CSS selectors to include attribute-based scoping
20
30
  #
21
31
  # Transforms:
@@ -26,25 +36,17 @@ module StyleCapsule
26
36
  # [data-capsule="a1b2c3d4"] .section { color: red; }
27
37
  # [data-capsule="a1b2c3d4"] .heading:hover { opacity: 0.8; }
28
38
  #
29
- # This approach uses attribute selectors (similar to Angular's Emulated View Encapsulation)
30
- # instead of renaming classes, ensuring styles only apply within scoped components.
31
- #
32
- # Simple approach:
33
- # - Strips CSS comments first to avoid interference
34
- # - Finds selectors before opening braces and prefixes them
35
- # - Handles @media queries (preserves them, scopes inner selectors)
36
- # - Handles :host and :host-context (component-scoped selectors)
39
+ # Uses attribute selectors instead of renaming classes, so styles apply only
40
+ # within scoped components. Leaves @keyframes / @font-face / @property steps alone.
37
41
  #
38
42
  # @param css_string [String] Original CSS content
39
43
  # @param capsule_id [String] The capsule ID to use in attribute selector
40
44
  # @param component_class [Class, String, nil] Optional component class for instrumentation
41
45
  # @return [String] CSS with scoped selectors
42
46
  # @raise [ArgumentError] If CSS content exceeds maximum size or capsule_id is invalid
43
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- selector patching walks CSS rules
44
47
  def self.scope_selectors(css_string, capsule_id, component_class: nil)
45
48
  return css_string if css_string.nil? || css_string.strip.empty?
46
49
 
47
- # Validate CSS size to prevent DoS attacks
48
50
  if css_string.bytesize > MAX_CSS_SIZE
49
51
  raise ArgumentError, "CSS content exceeds maximum size of #{MAX_CSS_SIZE} bytes (got #{css_string.bytesize} bytes)"
50
52
  end
@@ -52,72 +54,22 @@ module StyleCapsule
52
54
  reject_style_element_breakout!(css_string)
53
55
  validate_capsule_id!(capsule_id)
54
56
 
55
- # Instrument CSS processing with timing and size metrics
56
57
  Instrumentation.instrument_css_processing(
57
58
  strategy: :selector_patching,
58
59
  component_class: component_class || "Unknown",
59
60
  capsule_id: capsule_id,
60
61
  css_content: css_string
61
62
  ) do
62
- css = css_string.dup
63
63
  capsule_attr = %([data-capsule="#{capsule_id}"])
64
-
65
- # Strip CSS comments so they do not interfere with selector matching.
66
- # Comments are removed from the output (typical for production CSS).
67
- css_without_comments = strip_comments(css)
68
-
69
- # Process CSS rule by rule
70
- # Match: selector(s) { ... }
71
- # Pattern: (start or closing brace) + (whitespace) + (selector text) + (opening brace)
72
- # Note: Uses non-greedy quantifier ([^{}@]+?) to minimize backtracking
73
- # MAX_CSS_SIZE limit (1MB) mitigates ReDoS risk from malicious input
74
- css_without_comments.gsub!(/(^|\})(\s*)([^{}@]+?)(\{)/m) do |_|
75
- prefix = Regexp.last_match(1) # Previous closing brace or start
76
- whitespace = Regexp.last_match(2) # Whitespace between rules
77
- selectors_raw = Regexp.last_match(3) # The selector group
78
- selectors = selectors_raw.strip # Stripped for processing
79
- opening_brace = Regexp.last_match(4) # The opening brace
80
-
81
- # Skip at-rules (@media, @keyframes, etc.) - they should not be scoped at top level
82
- next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.start_with?("@")
83
-
84
- # Skip if already scoped (avoid double-scoping)
85
- next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors_raw.include?("[data-capsule=")
86
-
87
- # Skip empty selectors
88
- next "#{prefix}#{whitespace}#{selectors_raw}#{opening_brace}" if selectors.empty?
89
-
90
- # Split selectors by comma and scope each one
91
- scoped_selectors = selectors.split(",").map do |selector|
92
- selector = selector.strip
93
- next selector if selector.empty?
94
-
95
- # Handle special component-scoped selectors (:host, :host-context)
96
- if selector.start_with?(":host")
97
- selector = selector
98
- .gsub(/^:host-context\(([^)]+)\)/, "#{capsule_attr} \\1")
99
- .gsub(/^:host\(([^)]+)\)/, "#{capsule_attr}\\1")
100
- .gsub(/^:host\b/, capsule_attr)
101
- selector
102
- else
103
- # Add capsule attribute with space before selector for descendant matching
104
- # This ensures styles apply to elements inside the scoped wrapper
105
- "#{capsule_attr} #{selector}"
106
- end
107
- end.compact.join(", ")
108
-
109
- "#{prefix}#{whitespace}#{scoped_selectors}#{opening_brace}"
110
- end
111
-
112
- css_without_comments
64
+ patch_selectors(strip_comments(css_string.dup), capsule_attr)
113
65
  end
114
66
  end
115
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
116
67
 
117
- # Scope CSS using CSS nesting (wraps entire CSS in [data-capsule] { ... })
68
+ # Scope CSS using CSS nesting (wraps nestable rules in [data-capsule] { ... })
118
69
  #
119
- # This approach is more performant as it requires no CSS parsing or transformation.
120
- # However, it requires CSS nesting support in browsers (Chrome 112+, Firefox 117+, Safari 16.5+).
70
+ # Avoids per-selector rewrite. Requires CSS nesting support in browsers
71
+ # (Chrome 112+, Firefox 117+, Safari 16.5+). Non-nestable at-rules are written
72
+ # before the wrapper so @keyframes and @font-face stay valid.
121
73
  #
122
74
  # Transforms:
123
75
  # .section { color: red; }
@@ -132,12 +84,12 @@ module StyleCapsule
132
84
  # @param css_string [String] Original CSS content
133
85
  # @param capsule_id [String] The capsule ID to use in attribute selector
134
86
  # @param component_class [Class, String, nil] Optional component class for instrumentation
135
- # @return [String] CSS wrapped in nesting selector
87
+ # @return [String] CSS with nesting wrapper and hoisted at-rules
136
88
  # @raise [ArgumentError] If CSS content exceeds maximum size or capsule_id is invalid
89
+ # rubocop:disable Metrics/AbcSize -- size check, breakout, instrument, then hoist and wrap
137
90
  def self.scope_with_nesting(css_string, capsule_id, component_class: nil)
138
91
  return css_string if css_string.nil? || css_string.strip.empty?
139
92
 
140
- # Validate CSS size to prevent DoS attacks
141
93
  if css_string.bytesize > MAX_CSS_SIZE
142
94
  raise ArgumentError, "CSS content exceeds maximum size of #{MAX_CSS_SIZE} bytes (got #{css_string.bytesize} bytes)"
143
95
  end
@@ -145,27 +97,27 @@ module StyleCapsule
145
97
  reject_style_element_breakout!(css_string)
146
98
  validate_capsule_id!(capsule_id)
147
99
 
148
- # Instrument CSS processing with timing and size metrics
149
100
  Instrumentation.instrument_css_processing(
150
101
  strategy: :nesting,
151
102
  component_class: component_class || "Unknown",
152
103
  capsule_id: capsule_id,
153
104
  css_content: css_string
154
105
  ) do
155
- # Simply wrap the entire CSS in the capsule attribute selector
156
- # No parsing or transformation needed - much more performant
157
106
  capsule_attr = %([data-capsule="#{capsule_id}"])
158
- "#{capsule_attr} {\n#{css_string}\n}"
107
+ hoisted, nestable = hoist_top_level_at_rules(css_string)
108
+ parts = []
109
+ parts << hoisted unless hoisted.empty?
110
+ parts << "#{capsule_attr} {\n#{nestable}\n}" unless nestable.strip.empty?
111
+ parts.join("\n")
159
112
  end
160
113
  end
114
+ # rubocop:enable Metrics/AbcSize
161
115
 
162
116
  # Strip CSS comments (/* ... */) from the string
163
117
  #
164
118
  # @param css [String] CSS content with comments
165
119
  # @return [String] CSS content without comments
166
120
  def self.strip_comments(css)
167
- # Remove /* ... */ comments (including multi-line)
168
- # Use non-greedy match to handle multiple comments
169
121
  css.gsub(/\/\*.*?\*\//m, "")
170
122
  end
171
123
 
@@ -177,7 +129,10 @@ module StyleCapsule
177
129
  # @param css_string [String, nil] CSS that will be placed inside a style tag
178
130
  # @raise [ArgumentError] If the string contains a style element closer
179
131
  def self.reject_style_element_breakout!(css_string)
180
- return if css_string.blank?
132
+ # Core path must not call ActiveSupport String#blank?
133
+ # rubocop:disable Rails/Blank -- standalone hosts may lack ActiveSupport
134
+ return if css_string.nil? || css_string.empty?
135
+ # rubocop:enable Rails/Blank
181
136
  return unless css_string.match?(%r{</style}i)
182
137
 
183
138
  raise ArgumentError, "CSS content must not contain a style element closer"
@@ -192,23 +147,191 @@ module StyleCapsule
192
147
  raise ArgumentError, "capsule_id must be a String (got #{capsule_id.class})"
193
148
  end
194
149
 
195
- # Must not be empty (check first for clearer error message)
196
150
  if capsule_id.empty?
197
151
  raise ArgumentError, "capsule_id cannot be empty"
198
152
  end
199
153
 
200
- # Capsule ID should only contain alphanumeric characters, hyphens, and underscores
201
- # This prevents injection into HTML attributes
202
154
  unless capsule_id.match?(/\A[a-zA-Z0-9_-]+\z/)
203
155
  raise ArgumentError, "Invalid capsule_id: must contain only alphanumeric characters, hyphens, and underscores (got: #{capsule_id.inspect})"
204
156
  end
205
157
 
206
- # Reasonable length limit
207
158
  if capsule_id.length > 100
208
159
  raise ArgumentError, "Invalid capsule_id: too long (max 100 characters, got #{capsule_id.length})"
209
160
  end
210
161
  end
211
162
 
212
- private_class_method :strip_comments, :validate_capsule_id!
163
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- walks braces and at-rules in one pass
164
+ def self.patch_selectors(css, capsule_attr)
165
+ output = +""
166
+ index = 0
167
+ length = css.length
168
+ brace_depth = 0
169
+ suppress_below_depth = nil
170
+
171
+ while index < length
172
+ while index < length && css[index].match?(/\s/)
173
+ output << css[index]
174
+ index += 1
175
+ end
176
+ break if index >= length
177
+
178
+ if css[index] == "}"
179
+ output << "}"
180
+ index += 1
181
+ brace_depth -= 1
182
+ if !suppress_below_depth.nil? && brace_depth <= suppress_below_depth
183
+ suppress_below_depth = nil
184
+ end
185
+ next
186
+ end
187
+
188
+ selector_start = index
189
+ while index < length && css[index] != "{" && css[index] != "}"
190
+ index += 1
191
+ end
192
+
193
+ selectors_raw = css[selector_start...index]
194
+ if index >= length || css[index] == "}"
195
+ output << selectors_raw
196
+ next
197
+ end
198
+
199
+ selectors = selectors_raw.strip
200
+ at_name = selectors[/\A@([a-zA-Z-]+)/, 1]&.downcase
201
+ entering_non_scoping = at_name && NON_SCOPING_AT_RULES.include?(at_name) && suppress_below_depth.nil?
202
+
203
+ skip_scoping = !suppress_below_depth.nil? ||
204
+ selectors.start_with?("@") ||
205
+ selectors_raw.include?("[data-capsule=") ||
206
+ selectors.empty?
207
+
208
+ if skip_scoping
209
+ output << selectors_raw
210
+ if entering_non_scoping
211
+ suppress_below_depth = brace_depth
212
+ end
213
+ else
214
+ scoped_selectors = selectors.split(",").map do |selector|
215
+ selector = selector.strip
216
+ next selector if selector.empty?
217
+
218
+ if selector.start_with?(":host")
219
+ selector
220
+ .gsub(/^:host-context\(([^)]+)\)/, "#{capsule_attr} \\1")
221
+ .gsub(/^:host\(([^)]+)\)/, "#{capsule_attr}\\1")
222
+ .gsub(/^:host\b/, capsule_attr)
223
+ else
224
+ "#{capsule_attr} #{selector}"
225
+ end
226
+ end.join(", ")
227
+
228
+ leading = selectors_raw[/\A\s*/]
229
+ trailing = selectors_raw[/\s*\z/]
230
+ output << "#{leading}#{scoped_selectors}#{trailing}"
231
+ end
232
+
233
+ output << "{"
234
+ index += 1
235
+ brace_depth += 1
236
+ end
237
+
238
+ output
239
+ end
240
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
241
+
242
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- splits top-level CSS into hoistable and nestable parts
243
+ def self.hoist_top_level_at_rules(css)
244
+ hoisted = []
245
+ nestable = +""
246
+ index = 0
247
+ length = css.length
248
+
249
+ while index < length
250
+ while index < length && css[index].match?(/\s/)
251
+ nestable << css[index]
252
+ index += 1
253
+ end
254
+ break if index >= length
255
+
256
+ if css[index] == "@"
257
+ at_name = css[index..][/\A@([a-zA-Z-]+)/, 1]&.downcase
258
+ if at_name && HOISTABLE_AT_RULES.include?(at_name)
259
+ block, index = read_top_level_at_rule(css, index)
260
+ hoisted << block.strip
261
+ next
262
+ end
263
+ end
264
+
265
+ rule, index = read_top_level_rule(css, index)
266
+ nestable << rule
267
+ end
268
+
269
+ [hoisted.join("\n"), nestable]
270
+ end
271
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
272
+
273
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- semicolon vs braced at-rule forms
274
+ def self.read_top_level_at_rule(css, index)
275
+ start_index = index
276
+ length = css.length
277
+
278
+ while index < length && css[index] != "{" && css[index] != ";"
279
+ index += 1
280
+ end
281
+
282
+ if index < length && css[index] == ";"
283
+ index += 1
284
+ return [css[start_index...index], index]
285
+ end
286
+
287
+ raise ArgumentError, "Unterminated at-rule in CSS" if index >= length
288
+
289
+ index = consume_braced_block(css, index)
290
+ [css[start_index...index], index]
291
+ end
292
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
293
+
294
+ def self.read_top_level_rule(css, index)
295
+ start_index = index
296
+ length = css.length
297
+
298
+ while index < length && css[index] != "{"
299
+ index += 1
300
+ end
301
+
302
+ return [css[start_index...index], index] if index >= length
303
+
304
+ index = consume_braced_block(css, index)
305
+ [css[start_index...index], index]
306
+ end
307
+
308
+ # rubocop:disable Metrics/MethodLength -- brace depth walk with unterminated guard
309
+ def self.consume_braced_block(css, index)
310
+ length = css.length
311
+ raise ArgumentError, "Expected opening brace in CSS" unless css[index] == "{"
312
+
313
+ depth = 0
314
+ while index < length
315
+ case css[index]
316
+ when "{"
317
+ depth += 1
318
+ when "}"
319
+ depth -= 1
320
+ index += 1
321
+ return index if depth.zero?
322
+
323
+ next
324
+ end
325
+ index += 1
326
+ end
327
+
328
+ raise ArgumentError, "Unterminated CSS block"
329
+ end
330
+ # rubocop:enable Metrics/MethodLength
331
+
332
+ private_class_method :strip_comments, :validate_capsule_id!, :patch_selectors,
333
+ :hoist_top_level_at_rules, :read_top_level_at_rule, :read_top_level_rule,
334
+ :consume_braced_block
213
335
  end
336
+ # rubocop:enable Metrics/ModuleLength
214
337
  end
@@ -61,7 +61,10 @@ module StyleCapsule
61
61
  end
62
62
 
63
63
  def html_content_type?(content_type)
64
- return false if content_type.blank?
64
+ # Core path must not call ActiveSupport String#blank?
65
+ # rubocop:disable Rails/Blank -- standalone hosts may lack ActiveSupport
66
+ return false if content_type.nil? || content_type.empty?
67
+ # rubocop:enable Rails/Blank
65
68
 
66
69
  content_type.split(";", 2).first.to_s.strip.match?(HTML_CONTENT_TYPE)
67
70
  end
@@ -728,10 +728,16 @@ module StyleCapsule
728
728
  return html if pending_stylesheets.empty?
729
729
 
730
730
  closing_head_index = html.match(%r{</head>}i)&.begin(0)
731
- return html unless closing_head_index
731
+ unless closing_head_index
732
+ clear
733
+ return html
734
+ end
732
735
 
733
736
  tags = render_stylesheet_tags(pending_stylesheets, view_context)
734
- return html if tags.empty?
737
+ if tags.empty?
738
+ clear
739
+ return html
740
+ end
735
741
 
736
742
  injected = html.dup
737
743
  injected.insert(closing_head_index, "#{tags}\n")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StyleCapsule
4
- VERSION = "2.0.2"
4
+ VERSION = "2.0.3"
5
5
  end
@@ -167,7 +167,7 @@ module StyleCapsule
167
167
 
168
168
  # File caching is only allowed for class method component_styles
169
169
  if cache_strategy == :file && !file_caching_allowed?
170
- # Fall back to :none strategy if file caching requested but not allowed
170
+ warn_file_cache_fallback(self.class)
171
171
  cache_strategy = :none
172
172
  cache_ttl = nil
173
173
  cache_proc = nil
@@ -6,9 +6,12 @@ module StyleCapsule
6
6
  end
7
7
 
8
8
  module CssProcessor
9
+ MAX_CSS_SIZE: Integer
10
+ NON_SCOPING_AT_RULES: Array[String]
11
+ HOISTABLE_AT_RULES: Array[String]
12
+
9
13
  def self.scope_selectors: (String css_string, String capsule_id, ?component_class: Class | String | nil) -> String
10
14
  def self.scope_with_nesting: (String css_string, String capsule_id, ?component_class: Class | String | nil) -> String
11
- def self.strip_comments: (String css) -> String
12
15
  def self.reject_style_element_breakout!: (String | nil css_string) -> void
13
16
  end
14
17
 
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: 2.0.2
4
+ version: 2.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -273,11 +273,8 @@ dependencies:
273
273
  - - "~>"
274
274
  - !ruby/object:Gem::Version
275
275
  version: '4.0'
276
- description: Provides component-scoped CSS encapsulation using [data-capsule] attributes
277
- for Phlex components, ViewComponent components, and ERB templates. Styles are automatically
278
- scoped to prevent leakage between components. Inspired by component-based CSS approaches
279
- like Angular's view encapsulation and CSS modules. Works with Rails and can be used
280
- standalone in other Ruby frameworks (Sinatra, Hanami, etc.) or plain Ruby scripts.
276
+ description: Attribute-based CSS scoping via [data-capsule] for Phlex, ViewComponent,
277
+ and ERB. Works with Rails and standalone in other Ruby frameworks or plain Ruby.
281
278
  email:
282
279
  - contact@kiskolabs.com
283
280
  executables: []
@@ -290,10 +287,14 @@ files:
290
287
  - SECURITY.md
291
288
  - docs/changelogs/20260904180000_audit-fixes.md
292
289
  - docs/changelogs/20260925094000_head-injection-bufferable-chunked.md
290
+ - docs/changelogs/20260928193000_sync-appraisal-locks-after-version-bump.md
293
291
  - docs/dependencies/20260904174500_json-cve-2026-71847.md
294
292
  - docs/issues/20260904174500_engineering-and-dependency-audit.md
295
293
  - docs/issues/20260917123253_ci-drop-automatic-dependency-audit.md
294
+ - docs/issues/20261103150931_engineering-prose-release-audit.md
296
295
  - docs/non_rails_support.md
296
+ - docs/reviews/20261028_engineering-audit-release-preparation.md
297
+ - docs/reviews/20261103150931_engineering-prose-release-audit.md
297
298
  - lib/style_capsule.rb
298
299
  - lib/style_capsule/asset_path.rb
299
300
  - lib/style_capsule/class_registry.rb