Skip to content

Commit 136a4d9

Browse files
committed
feat(ruby): Rails integration gem
Signed-off-by: Dmitry Dygalo <dmitry@dygalo.dev>
1 parent 70b5ee1 commit 136a4d9

31 files changed

Lines changed: 1705 additions & 0 deletions

.github/workflows/build.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,64 @@ jobs:
770770
ruby -e "require \"css_inline\"; puts CSSInline.inline(\"<style>h1{color:red}</style><h1>test</h1>\")"
771771
'
772772
773+
test-ruby-rails:
774+
name: Rails ${{ matrix.rails-version }} / Ruby ${{ matrix.ruby-version }} / ${{ matrix.pipeline }}
775+
runs-on: ubuntu-24.04
776+
timeout-minutes: 30
777+
strategy:
778+
fail-fast: false
779+
matrix:
780+
# The boundaries: the floor the gemspec declares, and the newest.
781+
ruby-version: ["3.2", "4.0"]
782+
rails-version: ["7.1", "8.0"]
783+
# Rails boots one application per process, so each pipeline is a
784+
# separate job rather than a separate example.
785+
pipeline: [propshaft, sprockets]
786+
exclude:
787+
# Trims the matrix: Propshaft is the Rails 8 default, Sprockets
788+
# carries the older line. The loader specs cover both directly.
789+
- rails-version: "7.1"
790+
pipeline: propshaft
791+
792+
steps:
793+
- uses: actions/checkout@v7
794+
795+
- name: Set up Ruby & Rust
796+
uses: oxidize-rb/actions/setup-ruby-and-rust@v1.4.4
797+
with:
798+
ruby-version: ${{ matrix.ruby-version }}
799+
bundler-cache: false
800+
cargo-cache: true
801+
cache-version: v1-${{ matrix.ruby-version }}
802+
working-directory: ./bindings/ruby
803+
804+
# The wrapper depends on the sibling gem by path, so its extension has to
805+
# exist before the wrapper's bundle resolves.
806+
- name: Build the native extension
807+
run: |
808+
bundle install
809+
bundle exec rake compile
810+
working-directory: ./bindings/ruby
811+
812+
- name: Install dependencies
813+
run: bundle install
814+
working-directory: ./bindings/ruby/rails
815+
env:
816+
RAILS_VERSION: ${{ matrix.rails-version }}
817+
818+
- name: Unit specs
819+
run: bundle exec rake spec
820+
working-directory: ./bindings/ruby/rails
821+
env:
822+
RAILS_VERSION: ${{ matrix.rails-version }}
823+
824+
- name: Integration spec
825+
run: bundle exec rspec spec/integration
826+
working-directory: ./bindings/ruby/rails
827+
env:
828+
RAILS_VERSION: ${{ matrix.rails-version }}
829+
PIPELINE: ${{ matrix.pipeline }}
830+
773831
test-wasm:
774832
name: WASM module tests
775833
runs-on: ubuntu-22.04
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
name: "[Rails] Release"
2+
3+
on:
4+
push:
5+
tags:
6+
- rails-v*
7+
workflow_dispatch:
8+
inputs:
9+
dry_run:
10+
description: "Build and test the gem without publishing it"
11+
type: boolean
12+
default: true
13+
14+
concurrency:
15+
group: ${{ github.workflow }}-${{ github.ref }}
16+
cancel-in-progress: true
17+
18+
jobs:
19+
test:
20+
name: Ruby ${{ matrix.ruby-version }} / ${{ matrix.pipeline }}
21+
runs-on: ubuntu-24.04
22+
timeout-minutes: 30
23+
24+
strategy:
25+
fail-fast: false
26+
matrix:
27+
# The boundaries: the floor the gemspec declares, and the newest.
28+
ruby-version: ["3.2", "4.0"]
29+
pipeline: [propshaft, sprockets]
30+
31+
steps:
32+
- uses: actions/checkout@v7
33+
34+
- name: Set up Ruby & Rust
35+
uses: oxidize-rb/actions/setup-ruby-and-rust@v1.4.4
36+
with:
37+
ruby-version: ${{ matrix.ruby-version }}
38+
bundler-cache: false
39+
cargo-cache: true
40+
cache-version: v1-${{ matrix.ruby-version }}
41+
working-directory: ./bindings/ruby
42+
43+
# The wrapper depends on the sibling gem by path, so its extension has to
44+
# exist before the wrapper's bundle resolves.
45+
- name: Build the native extension
46+
run: |
47+
bundle install
48+
bundle exec rake compile
49+
working-directory: ./bindings/ruby
50+
51+
- name: Install dependencies
52+
run: bundle install
53+
working-directory: ./bindings/ruby/rails
54+
55+
- name: Unit specs
56+
run: bundle exec rake spec
57+
working-directory: ./bindings/ruby/rails
58+
59+
- name: Integration spec
60+
run: bundle exec rspec spec/integration
61+
working-directory: ./bindings/ruby/rails
62+
env:
63+
PIPELINE: ${{ matrix.pipeline }}
64+
65+
rails-release:
66+
needs: test
67+
runs-on: ubuntu-24.04
68+
env:
69+
# A manual run from a branch has no version to release.
70+
PUBLISH: ${{ !inputs.dry_run && startsWith(github.ref, 'refs/tags/') }}
71+
72+
steps:
73+
- uses: actions/checkout@v7
74+
75+
- name: Set up Ruby
76+
uses: ruby/setup-ruby@v1
77+
with:
78+
ruby-version: "3.4"
79+
80+
- name: Extract Version
81+
run: echo "version=${GITHUB_REF#refs/tags/rails-v}" >> $GITHUB_ENV
82+
83+
# Publishing a gem whose version does not match its tag cannot be undone.
84+
- name: Check the tag matches the gem version
85+
if: startsWith(github.ref, 'refs/tags/')
86+
working-directory: ./bindings/ruby/rails
87+
run: |
88+
gem_version=$(ruby -r ./lib/css_inline/rails/version -e "print CSSInline::Rails::VERSION")
89+
if [ "$gem_version" != "${{ env.version }}" ]; then
90+
echo "::error::Tag says ${{ env.version }}, version.rb says ${gem_version}"
91+
exit 1
92+
fi
93+
94+
- name: Build gem
95+
working-directory: ./bindings/ruby/rails
96+
run: gem build css_inline-rails.gemspec
97+
98+
# `spec.files` is a glob, so a new file in an unlisted directory would be
99+
# missing from the gem and only fail once someone installed it.
100+
- name: Check every source file was packaged
101+
working-directory: ./bindings/ruby/rails
102+
run: |
103+
gem spec css_inline-rails-*.gem files | sed -n 's/^- //p' | sort > packaged.txt
104+
find lib -name '*.rb' | sort > on-disk.txt
105+
missing=$(comm -13 packaged.txt on-disk.txt)
106+
if [ -n "$missing" ]; then
107+
echo "::error::Not packaged by the gemspec:"
108+
echo "$missing"
109+
exit 1
110+
fi
111+
112+
- name: GitHub Release
113+
if: env.PUBLISH == 'true'
114+
uses: softprops/action-gh-release@v3
115+
with:
116+
make_latest: false
117+
draft: true
118+
name: "[Rails] Release ${{ env.version }}"
119+
files: ./bindings/ruby/rails/*.gem
120+
121+
- name: Publish to RubyGems
122+
if: env.PUBLISH == 'true'
123+
working-directory: ./bindings/ruby/rails
124+
run: |
125+
mkdir -p $HOME/.gem
126+
touch $HOME/.gem/credentials
127+
chmod 0600 $HOME/.gem/credentials
128+
printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials
129+
gem push css_inline-rails-*.gem
130+
env:
131+
GEM_HOST_API_KEY: "${{ secrets.RUBYGEMS_API_KEY }}"
132+
133+
- name: Upload gem (no publish)
134+
if: env.PUBLISH != 'true'
135+
uses: actions/upload-artifact@v7
136+
with:
137+
name: gem
138+
path: ./bindings/ruby/rails/*.gem
139+
if-no-files-found: error

bindings/ruby/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
tmp
22
css_inline.so
3+
css_inline.bundle

bindings/ruby/rails/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/.bundle/
2+
/vendor/
3+
/pkg/
4+
Gemfile.lock
5+
/spec/integration/fixtures/tmp/
6+
/spec/integration/fixtures/log/

bindings/ruby/rails/.rspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
--format documentation

bindings/ruby/rails/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Changelog
2+
3+
## [Unreleased]
4+
5+
### Added
6+
7+
- Initial release. An ActionMailer interceptor that inlines CSS into outgoing HTML mail with `css_inline`.

bindings/ruby/rails/Gemfile

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
3+
source "https://rubygems.org"
4+
5+
gemspec
6+
7+
# The sibling native gem, built from this checkout rather than RubyGems.
8+
gem "css_inline", path: ".."
9+
10+
# Matches premailer-rails' approach: drive the Rails version from the
11+
# environment instead of pulling in Appraisal.
12+
rails_version = ENV.fetch("RAILS_VERSION", "8.0")
13+
14+
if rails_version == "head"
15+
gem "rails", github: "rails/rails"
16+
gem "propshaft", github: "rails/propshaft"
17+
else
18+
gem "rails", "~> #{rails_version}.0"
19+
gem "propshaft"
20+
end
21+
22+
# Both pipelines are installed so their loaders can be tested against the real
23+
# thing. Only one can be booted per process, so the integration spec picks with
24+
# PIPELINE; the loader specs drive both directly.
25+
gem "sprockets-rails"
26+
27+
gem "rspec", "~> 3.13"

bindings/ruby/rails/README.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# css_inline-rails
2+
3+
Inlines CSS into your Rails emails using [`css_inline`](https://github.com/Stranger6667/css-inline),
4+
which is 50-100x faster than `premailer` and `roadie`.
5+
6+
```ruby
7+
# Gemfile
8+
gem "css_inline-rails"
9+
```
10+
11+
That is the whole setup. An ActionMailer interceptor is registered automatically, and
12+
every outgoing HTML message gets its `<style>` blocks and linked stylesheets inlined
13+
into `style` attributes.
14+
15+
## Migrating from premailer-rails
16+
17+
**`premailer-rails` generates a `text/plain` part for you by default. This gem does not.**
18+
19+
If your mailers rely on `generate_text_part` and you swap gems without adding explicit
20+
`text_part` blocks, your emails go out HTML-only. That is a deliverability regression
21+
that no test will catch, so check your mailers before switching.
22+
23+
Everything else maps over:
24+
25+
| premailer-rails | css_inline-rails |
26+
|---|---|
27+
| `skip_premailer` header | `skip_css_inline` header |
28+
| `data-premailer="ignore"` | `data-css-inline="ignore"` — but see below |
29+
| `generate_text_part` | not supported — write a `text_part` |
30+
| `:network` strategy | not supported — remote links are skipped |
31+
32+
## How stylesheets are found
33+
34+
Mailer views usually link a stylesheet rather than inlining a `<style>` block:
35+
36+
```erb
37+
<%= stylesheet_link_tag "email" %>
38+
```
39+
40+
That renders a digested path such as `/assets/email-8f3a1c.css`, which only the asset
41+
pipeline can resolve. This gem resolves it locally, then hands the result to `css_inline`
42+
as `extra_css`. Three strategies are tried in order:
43+
44+
1. `:filesystem` — reads `public/`, where precompiled assets already live in production.
45+
2. `:sprockets` — the Sprockets manifest.
46+
3. `:propshaft` — Propshaft, the default pipeline since Rails 8.
47+
48+
If none of them resolve a linked stylesheet, `CSSHelper::FileNotFound` is raised rather
49+
than silently sending unstyled mail.
50+
51+
Remote stylesheets (`https://cdn.example.com/email.css`) are **not** fetched — downloading
52+
over the network while rendering mail is a footgun. They are skipped, not treated as an
53+
error, so a CDN font sheet will not fail a delivery. Link a local asset if you need it inlined.
54+
55+
`data-css-inline="ignore"` on a `link` differs from `data-premailer="ignore"`: premailer
56+
leaves the tag in the output, `css_inline` still removes it. The stylesheet is therefore
57+
neither inlined nor delivered. Set `keep_link_tags: true` in `inline_options` if you need
58+
the tag to survive.
59+
60+
## Configuration
61+
62+
```ruby
63+
# config/initializers/css_inline.rb
64+
CSSInline::Rails.config[:inline_options] = { keep_style_tags: true }
65+
CSSInline::Rails.config[:strategies] = %i[filesystem propshaft]
66+
```
67+
68+
`inline_options` is passed to `CSSInline.inline`; see the
69+
[`css_inline` options](https://github.com/Stranger6667/css-inline/tree/master/bindings/ruby#options).
70+
`load_remote_stylesheets` is always forced off, because the strategies above have already
71+
resolved every link.
72+
73+
Skip a single message:
74+
75+
```ruby
76+
mail(to: "user@example.com", skip_css_inline: true)
77+
```
78+
79+
## License
80+
81+
MIT

bindings/ruby/rails/Rakefile

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
require "bundler/gem_tasks"
4+
require "rspec/core/rake_task"
5+
6+
RSpec::Core::RakeTask.new(:spec) do |task|
7+
task.pattern = "spec/css_inline/**/*_spec.rb"
8+
end
9+
10+
# Rails permits one application per process, so each pipeline's integration spec
11+
# gets its own.
12+
namespace :integration do
13+
%w[propshaft sprockets].each do |pipeline|
14+
desc "Run the integration spec against #{pipeline}"
15+
task(pipeline) do
16+
sh({ "PIPELINE" => pipeline }, "bundle exec rspec spec/integration")
17+
end
18+
end
19+
end
20+
21+
desc "Run the integration spec against every pipeline"
22+
task integration: %w[integration:propshaft integration:sprockets]
23+
24+
task default: %i[spec integration]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "lib/css_inline/rails/version"
4+
5+
Gem::Specification.new do |spec|
6+
spec.name = "css_inline-rails"
7+
spec.version = CSSInline::Rails::VERSION
8+
# Pure Ruby, unlike the `css_inline` gem it wraps.
9+
spec.platform = Gem::Platform::RUBY
10+
spec.summary = "Inline CSS into Rails emails with css_inline"
11+
spec.description = <<-EOF
12+
Hooks `css_inline` into ActionMailer so that stylesheets linked from your
13+
mailer views are inlined into "style" attributes on delivery.
14+
EOF
15+
spec.files = [*Dir["lib/**/*.rb"], "README.md", "CHANGELOG.md"]
16+
spec.require_paths = ["lib"]
17+
spec.authors = ["Dmitry Dygalo"]
18+
spec.email = ["dmitry@dygalo.dev"]
19+
spec.homepage = "https://github.com/Stranger6667/css-inline"
20+
spec.license = "MIT"
21+
spec.metadata = {
22+
"bug_tracker_uri" => "https://github.com/Stranger6667/css-inline/issues",
23+
"changelog_uri" => "https://github.com/Stranger6667/css-inline/tree/master/bindings/ruby/rails/CHANGELOG.md",
24+
"source_code_uri" => "https://github.com/Stranger6667/css-inline/tree/master/bindings/ruby/rails",
25+
"funding_uri" => "https://github.com/sponsors/Stranger6667/"
26+
}
27+
28+
spec.required_ruby_version = ">= 3.2.0"
29+
30+
spec.add_dependency "actionmailer", ">= 7.0"
31+
spec.add_dependency "css_inline", "~> 0.21"
32+
# Used read-only, to find `link` hrefs. Already present in any Rails app via
33+
# actionview -> rails-html-sanitizer -> loofah.
34+
spec.add_dependency "nokogiri", ">= 1.13"
35+
end

0 commit comments

Comments
 (0)