Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,45 @@ jobs:
ruby -e "require \"css_inline\"; puts CSSInline.inline(\"<style>h1{color:red}</style><h1>test</h1>\")"
'

test-ruby-rails:
name: Rails ${{ matrix.rails-version }} / Ruby ${{ matrix.ruby-version }}
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
# The boundaries: the floor the gemspec declares, and the newest.
ruby-version: ["3.2", "4.0"]
rails-version: ["7.1", "8.0"]

steps:
- uses: actions/checkout@v7

- name: Set up Ruby & Rust
uses: oxidize-rb/actions/setup-ruby-and-rust@v1.4.4
with:
ruby-version: ${{ matrix.ruby-version }}
bundler-cache: false
cargo-cache: true
cache-version: v1-${{ matrix.ruby-version }}
working-directory: ./bindings/ruby

# The wrapper depends on the sibling gem by path, so its extension has to
# exist before the wrapper's bundle resolves.
- name: Build the native extension
run: |
bundle install
bundle exec rake compile
working-directory: ./bindings/ruby

- name: Run specs
run: |
bundle install
bundle exec rake
working-directory: ./bindings/ruby/rails
env:
RAILS_VERSION: ${{ matrix.rails-version }}

test-wasm:
name: WASM module tests
runs-on: ubuntu-22.04
Expand Down
77 changes: 77 additions & 0 deletions .github/workflows/rails-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
name: "[Rails] Release"

on:
push:
tags:
- rails-v*
workflow_dispatch:
inputs:
dry_run:
description: "Build the gem without publishing it"
type: boolean
default: true

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
rails-release:
runs-on: ubuntu-24.04
env:
# A manual run from a branch has no version to release.
PUBLISH: ${{ !inputs.dry_run && startsWith(github.ref, 'refs/tags/') }}

steps:
- uses: actions/checkout@v7

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4"

- name: Extract Version
run: echo "version=${GITHUB_REF#refs/tags/rails-v}" >> $GITHUB_ENV

- name: Check the tag matches the gem version
if: startsWith(github.ref, 'refs/tags/')
working-directory: ./bindings/ruby/rails
run: |
gem_version=$(ruby -r ./lib/css_inline/rails/version -e "print CSSInline::Rails::VERSION")
if [ "$gem_version" != "${{ env.version }}" ]; then
echo "::error::Tag says ${{ env.version }}, version.rb says ${gem_version}"
exit 1
fi

- name: Build gem
working-directory: ./bindings/ruby/rails
run: gem build css_inline-rails.gemspec

- name: GitHub Release
if: env.PUBLISH == 'true'
uses: softprops/action-gh-release@v3
with:
make_latest: false
draft: true
name: "[Rails] Release ${{ env.version }}"
files: ./bindings/ruby/rails/*.gem

- name: Publish to RubyGems
if: env.PUBLISH == 'true'
working-directory: ./bindings/ruby/rails
run: |
mkdir -p $HOME/.gem
touch $HOME/.gem/credentials
chmod 0600 $HOME/.gem/credentials
printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials
gem push css_inline-rails-*.gem
env:
GEM_HOST_API_KEY: "${{ secrets.RUBYGEMS_API_KEY }}"

- name: Upload gem (no publish)
if: env.PUBLISH != 'true'
uses: actions/upload-artifact@v7
with:
name: gem
path: ./bindings/ruby/rails/*.gem
if-no-files-found: error
21 changes: 15 additions & 6 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ release COMPONENT VERSION:
python) CL=bindings/python/CHANGELOG.md; FILES=(bindings/python/Cargo.toml) ;;
java) CL=bindings/java/CHANGELOG.md; FILES=(bindings/java/Cargo.toml bindings/java/build.gradle bindings/java/README.md) ;;
php) CL=bindings/php/CHANGELOG.md; FILES=(bindings/php/Cargo.toml bindings/php/stubs/css_inline.php) ;;
ruby) CL=bindings/ruby/CHANGELOG.md; FILES=(bindings/ruby/css_inline.gemspec bindings/ruby/ext/css_inline/Cargo.toml bindings/ruby/Gemfile.lock) ;;
ruby) CL=bindings/ruby/CHANGELOG.md; FILES=(bindings/ruby/css_inline.gemspec bindings/ruby/ext/css_inline/Cargo.toml bindings/ruby/Gemfile.lock bindings/ruby/rails/lib/css_inline/rails/version.rb) ;;
javascript) CL=bindings/javascript/CHANGELOG.md; FILES=(bindings/javascript/Cargo.toml bindings/javascript/package.json bindings/javascript/wasm/package.json bindings/javascript/npm/*/package.json) ;;
*) echo "unknown component: $C"; exit 1 ;;
esac
Expand All @@ -32,15 +32,24 @@ release COMPONENT VERSION:
grep -qiE "$ANCHOR" "$f" || { echo "no version line matching '${PREV}' in $f"; exit 1; }
sed -i "0,/${ANCHOR}/Is/${PREV_RE}/${VERSION}/" "$f"
done
sed -i "0,/^## \[Unreleased\]$/s//## [Unreleased]\n\n## [${VERSION}] - ${DATE}/" "$CL"
sed -i "s#compare/${C}-v${PREV_RE}\.\.\.HEAD#compare/${C}-v${VERSION}...HEAD#" "$CL"
sed -i "/^\[Unreleased\]: /a [${VERSION}]: https://github.com/Stranger6667/css-inline/compare/${C}-v${PREV}...${C}-v${VERSION}" "$CL"
bump_changelog() {
local cl="$1" tag_prefix="$2"
sed -i "0,/^## \[Unreleased\]$/s//## [Unreleased]\n\n## [${VERSION}] - ${DATE}/" "$cl"
sed -i "s#compare/${tag_prefix}-v${PREV_RE}\.\.\.HEAD#compare/${tag_prefix}-v${VERSION}...HEAD#" "$cl"
sed -i "/^\[Unreleased\]: /a [${VERSION}]: https://github.com/Stranger6667/css-inline/compare/${tag_prefix}-v${PREV}...${tag_prefix}-v${VERSION}" "$cl"
}
bump_changelog "$CL" "$C"
# Tags to create. Ruby also ships the Rails wrapper, which has its own gem,
# changelog and release workflow but moves in lockstep with `css_inline`.
TAGS=("${C}-v${VERSION}")
if [ "$C" = ruby ]; then
bump_changelog bindings/ruby/rails/CHANGELOG.md rails
TAGS+=("rails-v${VERSION}")
cargo update -p css-inline --manifest-path bindings/ruby/Cargo.toml
cp bindings/ruby/Cargo.lock bindings/ruby/ext/css_inline/Cargo.lock
fi
git add -u
git commit -m "chore(${C}): Release ${VERSION}"
git tag "${C}-v${VERSION}"
for t in "${TAGS[@]}"; do git tag "$t"; done
git push origin master
git push origin "${C}-v${VERSION}"
for t in "${TAGS[@]}"; do git push origin "$t"; done
1 change: 1 addition & 0 deletions bindings/ruby/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
tmp
css_inline.so
css_inline.bundle
6 changes: 6 additions & 0 deletions bindings/ruby/rails/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/.bundle/
/vendor/
/pkg/
Gemfile.lock
/spec/integration/fixtures/tmp/
/spec/integration/fixtures/log/
1 change: 1 addition & 0 deletions bindings/ruby/rails/.rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
--format documentation
9 changes: 9 additions & 0 deletions bindings/ruby/rails/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Changelog

## [Unreleased]

### Added

- Initial release. An ActionMailer interceptor that inlines CSS into outgoing HTML mail with `css_inline`.

[Unreleased]: https://github.com/Stranger6667/css-inline/compare/rails-v0.21.3...HEAD
27 changes: 27 additions & 0 deletions bindings/ruby/rails/Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

source "https://rubygems.org"

gemspec

# The sibling native gem, built from this checkout rather than RubyGems.
gem "css_inline", path: ".."

# Matches premailer-rails' approach: drive the Rails version from the
# environment instead of pulling in Appraisal.
rails_version = ENV.fetch("RAILS_VERSION", "8.0")

if rails_version == "head"
gem "rails", github: "rails/rails"
gem "propshaft", github: "rails/propshaft"
else
gem "rails", "~> #{rails_version}.0"
gem "propshaft"
end

# Both pipelines are installed so their loaders can be tested against the real
# thing. Only one can be booted per process, so the integration spec picks with
# PIPELINE; the loader specs drive both directly.
gem "sprockets-rails"

gem "rspec", "~> 3.13"
81 changes: 81 additions & 0 deletions bindings/ruby/rails/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# css_inline-rails

Inlines CSS into your Rails emails using [`css_inline`](https://github.com/Stranger6667/css-inline),
which is 50-100x faster than `premailer` and `roadie`.

```ruby
# Gemfile
gem "css_inline-rails"
```

That is the whole setup. An ActionMailer interceptor is registered automatically, and
every outgoing HTML message gets its `<style>` blocks and linked stylesheets inlined
into `style` attributes.

## Migrating from premailer-rails

**`premailer-rails` generates a `text/plain` part for you by default. This gem does not.**

If your mailers rely on `generate_text_part` and you swap gems without adding explicit
`text_part` blocks, your emails go out HTML-only. That is a deliverability regression
that no test will catch, so check your mailers before switching.

Everything else maps over:

| premailer-rails | css_inline-rails |
|---|---|
| `skip_premailer` header | `skip_css_inline` header |
| `data-premailer="ignore"` | `data-css-inline="ignore"` — but see below |
| `generate_text_part` | not supported — write a `text_part` |
| `:network` strategy | not supported — remote links are skipped |

## How stylesheets are found

Mailer views usually link a stylesheet rather than inlining a `<style>` block:

```erb
<%= stylesheet_link_tag "email" %>
```

That renders a digested path such as `/assets/email-8f3a1c.css`, which only the asset
pipeline can resolve. This gem resolves it locally, then hands the result to `css_inline`
as `extra_css`. Three strategies are tried in order:

1. `:filesystem` — reads `public/`, where precompiled assets already live in production.
2. `:sprockets` — the Sprockets manifest.
3. `:propshaft` — Propshaft, the default pipeline since Rails 8.

If none of them resolve a linked stylesheet, `CSSHelper::FileNotFound` is raised rather
than silently sending unstyled mail.

Remote stylesheets (`https://cdn.example.com/email.css`) are **not** fetched — downloading
over the network while rendering mail is a footgun. They are skipped, not treated as an
error, so a CDN font sheet will not fail a delivery. Link a local asset if you need it inlined.

`data-css-inline="ignore"` on a `link` differs from `data-premailer="ignore"`: premailer
leaves the tag in the output, `css_inline` still removes it. The stylesheet is therefore
neither inlined nor delivered. Set `keep_link_tags: true` in `inline_options` if you need
the tag to survive.

## Configuration

```ruby
# config/initializers/css_inline.rb
CSSInline::Rails.config[:inline_options] = { keep_style_tags: true }
CSSInline::Rails.config[:strategies] = %i[filesystem propshaft]
```

`inline_options` is passed to `CSSInline.inline`; see the
[`css_inline` options](https://github.com/Stranger6667/css-inline/tree/master/bindings/ruby#options).
`load_remote_stylesheets` is always forced off, because the strategies above have already
resolved every link.

Skip a single message:

```ruby
mail(to: "user@example.com", skip_css_inline: true)
```

## License

MIT
24 changes: 24 additions & 0 deletions bindings/ruby/rails/Rakefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

require "bundler/gem_tasks"
require "rspec/core/rake_task"

RSpec::Core::RakeTask.new(:spec) do |task|
task.pattern = "spec/css_inline/**/*_spec.rb"
end

# Rails permits one application per process, so each pipeline's integration spec
# gets its own.
namespace :integration do
%w[propshaft sprockets].each do |pipeline|
desc "Run the integration spec against #{pipeline}"
task(pipeline) do
sh({ "PIPELINE" => pipeline }, "bundle exec rspec spec/integration")
end
end
end

desc "Run the integration spec against every pipeline"
task integration: %w[integration:propshaft integration:sprockets]

task default: %i[spec integration]
37 changes: 37 additions & 0 deletions bindings/ruby/rails/css_inline-rails.gemspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# frozen_string_literal: true

require_relative "lib/css_inline/rails/version"

Gem::Specification.new do |spec|
spec.name = "css_inline-rails"
spec.version = CSSInline::Rails::VERSION
# Pure Ruby, unlike the `css_inline` gem it wraps.
spec.platform = Gem::Platform::RUBY
spec.summary = "Inline CSS into Rails emails with css_inline"
spec.description = <<-EOF
Hooks `css_inline` into ActionMailer so that stylesheets linked from your
mailer views are inlined into "style" attributes on delivery.
EOF
# `git ls-files`, as `bundle gem` and both premailer-rails and roadie-rails
# do, so a new file cannot be silently left out of the gem.
spec.files = `git ls-files -z -- lib README.md CHANGELOG.md`.split("\x0")
spec.require_paths = ["lib"]
spec.authors = ["Dmitry Dygalo"]
spec.email = ["dmitry@dygalo.dev"]
spec.homepage = "https://github.com/Stranger6667/css-inline"
spec.license = "MIT"
spec.metadata = {
"bug_tracker_uri" => "https://github.com/Stranger6667/css-inline/issues",
"changelog_uri" => "https://github.com/Stranger6667/css-inline/tree/master/bindings/ruby/rails/CHANGELOG.md",
"source_code_uri" => "https://github.com/Stranger6667/css-inline/tree/master/bindings/ruby/rails",
"funding_uri" => "https://github.com/sponsors/Stranger6667/"
}

spec.required_ruby_version = ">= 3.2.0"

spec.add_dependency "actionmailer", ">= 7.0"
spec.add_dependency "css_inline", "~> 0.21"
# Used read-only, to find `link` hrefs. Already present in any Rails app via
# actionview -> rails-html-sanitizer -> loofah.
spec.add_dependency "nokogiri", ">= 1.13"
end
Loading
Loading