{"id":50564789,"url":"https://github.com/amkisko/style_capsule.rb","last_synced_at":"2026-06-04T13:31:31.562Z","repository":{"id":325334238,"uuid":"1100369200","full_name":"amkisko/style_capsule.rb","owner":"amkisko","description":"CSS scoping extension for Rails components. Provides attribute-based style encapsulation for Phlex, ViewComponent, and ERB templates to prevent style leakage between components. Includes configurable caching strategies for optimal performance.","archived":false,"fork":false,"pushed_at":"2026-01-18T15:59:54.000Z","size":303,"stargazers_count":3,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-01-18T23:57:54.301Z","etag":null,"topics":["css-modules","css-scoping","encapsulation","rails","ruby","web-design"],"latest_commit_sha":null,"homepage":"https://rubygems.org/gems/style_capsule","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/amkisko.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":".github/FUNDING.yml","license":"LICENSE.md","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":"GOVERNANCE.md","roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":"amkisko"}},"created_at":"2025-11-20T07:32:41.000Z","updated_at":"2026-01-18T15:59:58.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/amkisko/style_capsule.rb","commit_stats":null,"previous_names":["amkisko/style_capsule.rb"],"tags_count":7,"template":false,"template_full_name":null,"purl":"pkg:github/amkisko/style_capsule.rb","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/amkisko%2Fstyle_capsule.rb","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/amkisko%2Fstyle_capsule.rb/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/amkisko%2Fstyle_capsule.rb/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/amkisko%2Fstyle_capsule.rb/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/amkisko","download_url":"https://codeload.github.com/amkisko/style_capsule.rb/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/amkisko%2Fstyle_capsule.rb/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33907693,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-04T02:00:06.755Z","response_time":64,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["css-modules","css-scoping","encapsulation","rails","ruby","web-design"],"created_at":"2026-06-04T13:31:29.239Z","updated_at":"2026-06-04T13:31:31.556Z","avatar_url":"https://github.com/amkisko.png","language":"Ruby","funding_links":["https://github.com/sponsors/amkisko"],"categories":[],"sub_categories":[],"readme":"# style_capsule\n\n[![Gem Version](https://badge.fury.io/rb/style_capsule.svg?v=1.4.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)\n\nCSS 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.\n\nSponsored by [Kisko Labs](https://www.kiskolabs.com).\n\n\u003ca href=\"https://www.kiskolabs.com\"\u003e\n  \u003cimg src=\"kisko.svg\" width=\"200\" alt=\"Sponsored by Kisko Labs\" /\u003e\n\u003c/a\u003e\n\n## Installation\n\nAdd to your Gemfile:\n\n```ruby\ngem \"style_capsule\"\n```\n\nThen run `bundle install`.\n\n## Features\n\n- **Attribute-based CSS scoping** (no class name renaming)\n- **Phlex, ViewComponent, and ERB support** with automatic Rails integration\n- **Per-component-type scope IDs** (shared across instances)\n- **CSS Nesting support** (optional, more performant, requires modern browsers)\n- **Stylesheet registry** with thread-safe head rendering, namespace support, and compatibility with Propshaft and other asset bundlers\n- **Multiple cache strategies**: none, time-based, custom proc, and file-based (HTTP caching)\n- **Comprehensive instrumentation** via ActiveSupport::Notifications for monitoring and metrics\n- **Fallback directory support** for read-only filesystems (e.g., Docker containers)\n- **Security protections**: path traversal protection, input validation, size limits\n\n## Usage\n\n### Phlex Components\n\n```ruby\nclass MyComponent \u003c ApplicationComponent\n  include StyleCapsule::Component\n\n  def component_styles\n    \u003c\u003c~CSS\n      .section { color: red; }\n      .heading:hover { opacity: 0.8; }\n    CSS\n  end\n\n  def view_template\n    div(class: \"section\") do\n      h2(class: \"heading\") { \"Hello\" }\n    end\n  end\nend\n```\n\nCSS is automatically scoped with `[data-capsule=\"...\"]` attributes and content is wrapped in a scoped element.\n\n### ViewComponent\n\n```ruby\nclass MyComponent \u003c ApplicationComponent\n  include StyleCapsule::ViewComponent\n\n  def component_styles\n    \u003c\u003c~CSS\n      .section { color: red; }\n    CSS\n  end\n\n  def call\n    content_tag :div, class: \"section\" do\n      \"Hello\"\n    end\n  end\nend\n```\n\n### ERB Templates\n\n```erb\n\u003c%= style_capsule do %\u003e\n  \u003cstyle\u003e\n    .section { color: red; }\n  \u003c/style\u003e\n  \u003cdiv class=\"section\"\u003eContent\u003c/div\u003e\n\u003c% end %\u003e\n```\n\n**With custom wrapper tag:**\n\n```erb\n\u003c%= style_capsule(tag: :section) do %\u003e\n  \u003cstyle\u003e\n    .section { color: red; }\n  \u003c/style\u003e\n  \u003cdiv class=\"section\"\u003eContent\u003c/div\u003e\n\u003c% end %\u003e\n```\n\n## CSS Scoping Strategies\n\nStyleCapsule supports two CSS scoping strategies:\n\n1. **Selector Patching (default)**: Adds `[data-capsule=\"...\"]` prefix to each selector\n   - Better browser support (all modern browsers)\n   - Output: `[data-capsule=\"abc123\"] .section { color: red; }`\n\n2. **CSS Nesting (optional)**: Wraps entire CSS in `[data-capsule=\"...\"] { ... }`\n   - More performant (no CSS parsing needed)\n   - Requires CSS nesting support (Chrome 112+, Firefox 117+, Safari 16.5+)\n   - Output: `[data-capsule=\"abc123\"] { .section { color: red; } }`\n\n### Configuration\n\n**Per-component (using `style_capsule` - recommended):**\n\n```ruby\nclass MyComponent \u003c ApplicationComponent\n  include StyleCapsule::Component\n  style_capsule scoping_strategy: :nesting  # Use CSS nesting\nend\n```\n\n**With custom wrapper tag:**\n\n```ruby\nclass MyComponent \u003c ApplicationComponent\n  include StyleCapsule::Component\n  style_capsule tag: :section  # Use \u003csection\u003e instead of \u003cdiv\u003e for wrapper\nend\n```\n\n**Global (in base component class):**\n\n```ruby\nclass ApplicationComponent \u003c Phlex::HTML\n  include StyleCapsule::Component\n  style_capsule scoping_strategy: :nesting  # Enable for all components\nend\n```\n\n**Note:** If you change the strategy and it doesn't take effect, clear the CSS cache:\n\n```ruby\nMyComponent.clear_css_cache\n```\n\n## Stylesheet Registry\n\nFor better performance, register styles for head rendering instead of rendering `\u003cstyle\u003e` tags in the body. Use the unified `style_capsule` method to configure all settings:\n\n```ruby\nclass MyComponent \u003c ApplicationComponent\n  include StyleCapsule::Component\n  style_capsule namespace: :admin  # Configure namespace and enable head rendering\n\n  def component_styles\n    \u003c\u003c~CSS\n      .section { color: red; }\n    CSS\n  end\nend\n```\n\nWith cache strategy and CSS scoping:\n\n```ruby\nclass MyComponent \u003c ApplicationComponent\n  include StyleCapsule::Component\n  style_capsule(\n    namespace: :admin,\n    cache_strategy: :time,\n    cache_ttl: 1.hour,\n    scoping_strategy: :nesting\n  )\n\n  def component_styles\n    \u003c\u003c~CSS\n      .section { color: red; }\n    CSS\n  end\nend\n```\n\nThen in your layout (render only the namespace you need):\n\n```erb\n\u003chead\u003e\n  \u003c%= stylesheet_registry_tags(namespace: :admin) %\u003e\n\u003c/head\u003e\n```\n\nOr in Phlex (requires including `StyleCapsule::PhlexHelper`):\n\n```ruby\nhead do\n  stylesheet_registry_tags(namespace: :admin)\nend\n```\n\n**Namespace Isolation:** Using namespaces prevents stylesheet leakage between different application contexts. For example, login pages can use `namespace: :login`, ActiveAdmin can use `namespace: :active_admin`, and user components can use `namespace: :user`. Each namespace is rendered separately, improving caching efficiency and preventing style conflicts.\n\n### Registering Stylesheet Files\n\nYou can also register external stylesheet files (not inline CSS) for head rendering. When a component has a configured namespace via `style_capsule`, you don't need to specify it every time:\n\n**In ERB:**\n\n```erb\n\u003c% register_stylesheet(\"stylesheets/user/my_component\", \"data-turbo-track\": \"reload\") %\u003e\n\u003c% register_stylesheet(\"stylesheets/admin/dashboard\", namespace: :admin) %\u003e\n```\n\n**In Phlex (requires including `StyleCapsule::PhlexHelper`):**\n\n```ruby\nclass UserComponent \u003c ApplicationComponent\n  include StyleCapsule::Component\n  include StyleCapsule::PhlexHelper\n  style_capsule namespace: :user  # Set default namespace\n\n  def view_template\n    # Namespace automatically uses :user from style_capsule\n    register_stylesheet(\"stylesheets/user/my_component\", \"data-turbo-track\": \"reload\")\n    # Can still override namespace if needed\n    register_stylesheet(\"stylesheets/shared/common\", namespace: :shared)\n    div { \"Content\" }\n  end\nend\n```\n\n**In ViewComponent (requires including `StyleCapsule::ViewComponentHelper`):**\n\n```ruby\nclass UserComponent \u003c ApplicationComponent\n  include StyleCapsule::ViewComponent\n  include StyleCapsule::ViewComponentHelper\n  style_capsule namespace: :user  # Set default namespace\n\n  def call\n    # Namespace automatically uses :user from style_capsule\n    register_stylesheet(\"stylesheets/user/my_component\", \"data-turbo-track\": \"reload\")\n    content_tag(:div, \"Content\")\n  end\nend\n```\n\nRegistered files are rendered via `stylesheet_registry_tags` in your layout, just like inline CSS. The namespace is automatically used from the component's `style_capsule` configuration when not explicitly specified.\n\n## Caching Strategies\n\n### No Caching (Default)\n\n```ruby\nclass MyComponent \u003c ApplicationComponent\n  include StyleCapsule::Component\n  style_capsule  # No cache strategy set (default: :none)\nend\n```\n\n### Time-Based Caching\n\n```ruby\nstyle_capsule cache_strategy: :time, cache_ttl: 1.hour  # Using ActiveSupport::Duration\n# Or using integer seconds:\nstyle_capsule cache_strategy: :time, cache_ttl: 3600  # Cache for 1 hour\n```\n\n### Custom Proc Caching\n\n```ruby\nstyle_capsule cache_strategy: -\u003e(css, capsule_id, namespace) {\n  cache_key = \"css_#{capsule_id}_#{namespace}\"\n  should_cache = css.length \u003e 100\n  expires_at = Time.now + 1800\n  [cache_key, should_cache, expires_at]\n}\n```\n\n**Note:** `cache_strategy` accepts Symbol (`:time`), String (`\"time\"`), or Proc. Strings are automatically converted to symbols.\n\n### File-Based Caching (HTTP Caching)\n\nWrites CSS to files for HTTP caching. **Requires class method `def self.component_styles`**:\n\n```ruby\nclass MyComponent \u003c ApplicationComponent\n  include StyleCapsule::Component\n  style_capsule cache_strategy: :file\n\n  # Must use class method for file caching\n  def self.component_styles\n    \u003c\u003c~CSS\n      .section { color: red; }\n    CSS\n  end\nend\n```\n\n**Configuration:**\n\n```ruby\n# config/initializers/style_capsule.rb\nStyleCapsule::CssFileWriter.configure(\n  output_dir: Rails.root.join(\"app/assets/builds/capsules\"),\n  filename_pattern: -\u003e(component_class, capsule_id) {\n    \"capsule-#{capsule_id}.css\"\n  },\n  fallback_dir: \"/tmp/style_capsule\"  # Optional, defaults to /tmp/style_capsule\n)\n```\n\n**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.\n\n**Precompilation:**\n\n```bash\nbin/rails style_capsule:build  # Build CSS files\nbin/rails style_capsule:clear  # Clear generated files\n```\n\nFiles are automatically built during `bin/rails assets:precompile`.\n\n**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.\n\n## Instrumentation\n\nStyleCapsule provides comprehensive instrumentation via `ActiveSupport::Notifications` for monitoring CSS processing and file writing operations. All instrumentation is zero-overhead when no subscribers are present.\n\n### Available Events\n\n- `style_capsule.css_processor.scope` - CSS scoping operations with duration and size metrics\n- `style_capsule.css_file_writer.write` - CSS file write operations with duration and size metrics\n- `style_capsule.css_file_writer.fallback` - When fallback directory is used (read-only filesystem)\n- `style_capsule.css_file_writer.fallback_failure` - When both primary and fallback directories fail\n- `style_capsule.css_file_writer.write_failure` - Other write errors\n\n### Example: Monitoring CSS Processing\n\n```ruby\n# config/initializers/style_capsule.rb\nActiveSupport::Notifications.subscribe(\"style_capsule.css_processor.scope\") do |name, start, finish, id, payload|\n  duration_ms = (finish - start) * 1000\n  Rails.logger.info \"CSS scoped in #{duration_ms.round(2)}ms, input: #{payload[:input_size]} bytes, output: #{payload[:output_size]} bytes\"\nend\n```\n\n### Example: Monitoring File Writes\n\n```ruby\nActiveSupport::Notifications.subscribe(\"style_capsule.css_file_writer.write\") do |name, start, finish, id, payload|\n  duration_ms = (finish - start) * 1000\n  StatsD.timing(\"style_capsule.write.duration\", duration_ms)\n  StatsD.histogram(\"style_capsule.write.size\", payload[:size])\nend\n```\n\n### Example: Monitoring Fallback Scenarios\n\n```ruby\nActiveSupport::Notifications.subscribe(\"style_capsule.css_file_writer.fallback\") do |name, start, finish, id, payload|\n  Rails.logger.warn \"StyleCapsule fallback used: #{payload[:component_class]} -\u003e #{payload[:fallback_path]}\"\n  # Exception info available: payload[:exception] and payload[:exception_object]\n  StatsD.increment(\"style_capsule.css_file_writer.fallback\", tags: [\n    \"component:#{payload[:component_class]}\",\n    \"error:#{payload[:exception].first}\"\n  ])\nend\n```\n\n### Example: Error Reporting\n\n```ruby\nActiveSupport::Notifications.subscribe(\"style_capsule.css_file_writer.fallback_failure\") do |name, start, finish, id, payload|\n  ActionReporter.notify(\n    \"StyleCapsule: CSS write failure (both primary and fallback failed)\",\n    context: {\n      component_class: payload[:component_class],\n      original_path: payload[:original_path],\n      fallback_path: payload[:fallback_path],\n      original_exception: payload[:original_exception],\n      fallback_exception: payload[:fallback_exception]\n    }\n  )\nend\n```\n\nFor more details, see the [ActiveSupport::Notifications documentation](https://guides.rubyonrails.org/active_support_instrumentation.html).\n\n## Advanced Usage\n\n### Database-Stored CSS\n\nFor CSS stored in a database (e.g., user-generated styles, themes), use StyleCapsule's CSS processor directly:\n\n```ruby\n# app/models/theme.rb\nclass Theme \u003c ApplicationRecord\n  def generate_capsule_id\n    return capsule_id if capsule_id.present?\n    scope_key = \"theme_#{id}_#{name}\"\n    self.capsule_id = \"a#{Digest::SHA1.hexdigest(scope_key)}\"[0, 8]\n    save! if persisted?\n    capsule_id\n  end\n\n  def scoped_css\n    return scoped_css_cache if scoped_css_cache.present? \u0026\u0026 \n                               scoped_css_updated_at == updated_at\n    \n    current_capsule_id = generate_capsule_id\n    scoped = StyleCapsule::CssProcessor.scope_selectors(css_content, current_capsule_id)\n    \n    update_columns(\n      scoped_css_cache: scoped,\n      scoped_css_updated_at: updated_at,\n      capsule_id: current_capsule_id\n    )\n    \n    scoped\n  end\nend\n```\n\n**Usage:**\n\n```erb\n\u003cdiv data-capsule=\"\u003c%= theme.capsule_id %\u003e\"\u003e\n  \u003cstyle\u003e\u003c%= raw theme.scoped_css %\u003e\u003c/style\u003e\n  \u003cdiv class=\"header\"\u003eContent\u003c/div\u003e\n\u003c/div\u003e\n```\n\n## CSS Selector Support\n\n- Regular selectors: `.section`, `#header`, `div.container`\n- Pseudo-classes and pseudo-elements: `.button:hover`, `.item::before`\n- Multiple selectors: `.a, .b, .c { color: red; }`\n- Component-scoped selectors: `:host`, `:host(.active)`, `:host-context(.theme-dark)`\n- Media queries: `@media (max-width: 768px) { ... }`\n\n## Requirements\n\n- Ruby \u003e= 3.0\n- Rails \u003e= 6.0, \u003c 9.0 (optional, for Rails integration)\n- ActiveSupport \u003e= 6.0, \u003c 9.0 (optional, for Rails integration)\n\n**Note**: The gem can be used without Rails! See [Non-Rails Support](#non-rails-support) below.\n\n## Non-Rails Support\n\nStyleCapsule can be used without Rails! The core functionality is framework-agnostic.\n\n### Standalone Usage\n\n```ruby\nrequire 'style_capsule'\n\n# Direct CSS processing\ncss = \".section { color: red; }\"\ncapsule_id = \"abc123\"\nscoped = StyleCapsule::CssProcessor.scope_selectors(css, capsule_id)\n# =\u003e \"[data-capsule=\\\"abc123\\\"] .section { color: red; }\"\n```\n\n### Phlex Without Rails\n\n```ruby\nrequire 'phlex'\nrequire 'style_capsule'\n\nclass MyComponent \u003c Phlex::HTML\n  include StyleCapsule::Component\n  \n  def component_styles\n    \u003c\u003c~CSS\n      .section { color: red; }\n    CSS\n  end\n  \n  def view_template\n    div(class: \"section\") { \"Hello\" }\n  end\nend\n```\n\n### Sinatra\n\n```ruby\nrequire 'sinatra'\nrequire 'style_capsule'\n\nclass MyApp \u003c Sinatra::Base\n  helpers StyleCapsule::StandaloneHelper\n  \n  get '/' do\n    erb :index\n  end\nend\n```\n\n```erb\n\u003c!-- views/index.erb --\u003e\n\u003c%= style_capsule do %\u003e\n  \u003cstyle\u003e\n    .section { color: red; }\n  \u003c/style\u003e\n  \u003cdiv class=\"section\"\u003eContent\u003c/div\u003e\n\u003c% end %\u003e\n```\n\n### Stylesheet Registry Without Rails\n\nThe stylesheet registry automatically uses thread-local storage when ActiveSupport is not available:\n\n```ruby\nrequire 'style_capsule'\n\n# Works without Rails\nStyleCapsule::StylesheetRegistry.register_inline(\".test { color: red; }\", namespace: :test)\nstylesheets = StyleCapsule::StylesheetRegistry.request_inline_stylesheets\n```\n\nFor more details, see [docs/non_rails_support.md](docs/non_rails_support.md).\n\n## How It Works\n\n1. **Scope ID Generation**: Each component class gets a unique scope ID based on its class name (shared across all instances)\n2. **CSS Rewriting**: CSS selectors are rewritten to include `[data-capsule=\"...\"]` attribute selectors\n3. **HTML Wrapping**: Component content is automatically wrapped in a scoped element\n4. **No Class Renaming**: Class names remain unchanged (unlike Shadow DOM)\n\n## Development\n\n```bash\nbundle install\nbundle exec appraisal install\n\n# Run tests\nbundle exec rspec\n\n# Run tests for all Rails versions\nbundle exec appraisal rails72 rspec\nbundle exec appraisal rails8ruby34 rspec\n\n# Linting\nbundle exec standardrb --fix\n```\n\n## Contributing\n\nBug reports and pull requests are welcome on GitHub at https://github.com/amkisko/style_capsule.rb\n\nContribution policy:\n- New features are not necessarily added to the gem\n- Pull requests should have test coverage and changelog entry\n\nReview policy:\n- Critical fixes: up to 2 calendar weeks\n- Pull requests: up to 6 calendar months\n- Issues: up to 1 calendar year\n\n## Publishing\n\n```sh\nrm style_capsule-*.gem\ngem build style_capsule.gemspec\ngem push style_capsule-*.gem\n```\n\n## Security\n\nStyleCapsule includes security protections:\n- Path traversal protection\n- Input validation\n- Size limits (1MB per component)\n- XSS prevention via Rails' HTML escaping\n\nFor detailed security information, see [SECURITY.md](SECURITY.md).\n\n## License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Famkisko%2Fstyle_capsule.rb","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Famkisko%2Fstyle_capsule.rb","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Famkisko%2Fstyle_capsule.rb/lists"}