From 9dae39bf2a8b7f8937367995d53c7e2c4a25315b Mon Sep 17 00:00:00 2001 From: Dmitry Dygalo Date: Fri, 28 Aug 2026 20:13:48 +0200 Subject: [PATCH] feat(ruby): Rails integration gem Signed-off-by: Dmitry Dygalo --- .github/workflows/build.yml | 39 ++++ .github/workflows/rails-release.yml | 77 +++++++ Justfile | 21 +- bindings/ruby/.gitignore | 1 + bindings/ruby/rails/.gitignore | 6 + bindings/ruby/rails/.rspec | 1 + bindings/ruby/rails/CHANGELOG.md | 9 + bindings/ruby/rails/Gemfile | 27 +++ bindings/ruby/rails/README.md | 81 +++++++ bindings/ruby/rails/Rakefile | 24 +++ bindings/ruby/rails/css_inline-rails.gemspec | 37 ++++ bindings/ruby/rails/lib/css_inline/rails.rb | 45 ++++ .../rails/lib/css_inline/rails/css_helper.rb | 93 ++++++++ .../rails/lib/css_inline/rails/css_loaders.rb | 85 ++++++++ .../rails/css_loaders/file_system_loader.rb | 45 ++++ .../rails/css_loaders/propshaft_loader.rb | 48 +++++ .../rails/css_loaders/sprockets_loader.rb | 42 ++++ .../ruby/rails/lib/css_inline/rails/hook.rb | 64 ++++++ .../rails/lib/css_inline/rails/railtie.rb | 11 + .../rails/lib/css_inline/rails/version.rb | 8 + .../spec/css_inline/rails/css_helper_spec.rb | 130 +++++++++++ .../spec/css_inline/rails/css_loaders_spec.rb | 202 ++++++++++++++++++ .../spec/css_inline/rails/hook_mime_spec.rb | 142 ++++++++++++ .../rails/spec/css_inline/rails/hook_spec.rb | 105 +++++++++ .../ruby/rails/spec/css_inline/rails_spec.rb | 42 ++++ .../ruby/rails/spec/integration/app_spec.rb | 96 +++++++++ .../fixtures/app/assets/config/manifest.js | 1 + .../fixtures/app/assets/stylesheets/email.css | 3 + .../app/views/test_mailer/hello.html.erb | 4 + bindings/ruby/rails/spec/spec_helper.rb | 81 +++++++ bindings/ruby/rails/spec/support/pipelines.rb | 69 ++++++ .../rails/spec/support/rails_application.rb | 49 +++++ 32 files changed, 1682 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/rails-release.yml create mode 100644 bindings/ruby/rails/.gitignore create mode 100644 bindings/ruby/rails/.rspec create mode 100644 bindings/ruby/rails/CHANGELOG.md create mode 100644 bindings/ruby/rails/Gemfile create mode 100644 bindings/ruby/rails/README.md create mode 100644 bindings/ruby/rails/Rakefile create mode 100644 bindings/ruby/rails/css_inline-rails.gemspec create mode 100644 bindings/ruby/rails/lib/css_inline/rails.rb create mode 100644 bindings/ruby/rails/lib/css_inline/rails/css_helper.rb create mode 100644 bindings/ruby/rails/lib/css_inline/rails/css_loaders.rb create mode 100644 bindings/ruby/rails/lib/css_inline/rails/css_loaders/file_system_loader.rb create mode 100644 bindings/ruby/rails/lib/css_inline/rails/css_loaders/propshaft_loader.rb create mode 100644 bindings/ruby/rails/lib/css_inline/rails/css_loaders/sprockets_loader.rb create mode 100644 bindings/ruby/rails/lib/css_inline/rails/hook.rb create mode 100644 bindings/ruby/rails/lib/css_inline/rails/railtie.rb create mode 100644 bindings/ruby/rails/lib/css_inline/rails/version.rb create mode 100644 bindings/ruby/rails/spec/css_inline/rails/css_helper_spec.rb create mode 100644 bindings/ruby/rails/spec/css_inline/rails/css_loaders_spec.rb create mode 100644 bindings/ruby/rails/spec/css_inline/rails/hook_mime_spec.rb create mode 100644 bindings/ruby/rails/spec/css_inline/rails/hook_spec.rb create mode 100644 bindings/ruby/rails/spec/css_inline/rails_spec.rb create mode 100644 bindings/ruby/rails/spec/integration/app_spec.rb create mode 100644 bindings/ruby/rails/spec/integration/fixtures/app/assets/config/manifest.js create mode 100644 bindings/ruby/rails/spec/integration/fixtures/app/assets/stylesheets/email.css create mode 100644 bindings/ruby/rails/spec/integration/fixtures/app/views/test_mailer/hello.html.erb create mode 100644 bindings/ruby/rails/spec/spec_helper.rb create mode 100644 bindings/ruby/rails/spec/support/pipelines.rb create mode 100644 bindings/ruby/rails/spec/support/rails_application.rb diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5eb0ff92..caab78cb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -770,6 +770,45 @@ jobs: ruby -e "require \"css_inline\"; puts CSSInline.inline(\"

test

\")" ' + 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 diff --git a/.github/workflows/rails-release.yml b/.github/workflows/rails-release.yml new file mode 100644 index 00000000..48945a44 --- /dev/null +++ b/.github/workflows/rails-release.yml @@ -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 diff --git a/Justfile b/Justfile index 3e5dc671..88b954e5 100644 --- a/Justfile +++ b/Justfile @@ -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 @@ -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 diff --git a/bindings/ruby/.gitignore b/bindings/ruby/.gitignore index 5bcde9d4..096d4b81 100644 --- a/bindings/ruby/.gitignore +++ b/bindings/ruby/.gitignore @@ -1,2 +1,3 @@ tmp css_inline.so +css_inline.bundle diff --git a/bindings/ruby/rails/.gitignore b/bindings/ruby/rails/.gitignore new file mode 100644 index 00000000..f1b632e9 --- /dev/null +++ b/bindings/ruby/rails/.gitignore @@ -0,0 +1,6 @@ +/.bundle/ +/vendor/ +/pkg/ +Gemfile.lock +/spec/integration/fixtures/tmp/ +/spec/integration/fixtures/log/ diff --git a/bindings/ruby/rails/.rspec b/bindings/ruby/rails/.rspec new file mode 100644 index 00000000..49d5710b --- /dev/null +++ b/bindings/ruby/rails/.rspec @@ -0,0 +1 @@ +--format documentation diff --git a/bindings/ruby/rails/CHANGELOG.md b/bindings/ruby/rails/CHANGELOG.md new file mode 100644 index 00000000..69eb7a44 --- /dev/null +++ b/bindings/ruby/rails/CHANGELOG.md @@ -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 diff --git a/bindings/ruby/rails/Gemfile b/bindings/ruby/rails/Gemfile new file mode 100644 index 00000000..9ce235d8 --- /dev/null +++ b/bindings/ruby/rails/Gemfile @@ -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" diff --git a/bindings/ruby/rails/README.md b/bindings/ruby/rails/README.md new file mode 100644 index 00000000..a76dfdfe --- /dev/null +++ b/bindings/ruby/rails/README.md @@ -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 ` +

Lorem ipsum

+ HTML + + def base_message + Mail.new do + to "some@email.com" + from "from@example.com" + subject "testing css_inline-rails" + end + end + + def deliver(message) + described_class.delivering_email(message) + end + + describe "multipart/mixed with an attachment around a multipart/alternative" do + subject(:message) do + message = base_message + alternative = Mail::Part.new(content_type: "multipart/alternative") + alternative.text_part = Mail::Part.new do + content_type "text/plain; charset=UTF-8" + body "Lorem ipsum" + end + alternative.html_part = Mail::Part.new do + content_type "text/html; charset=UTF-8" + body HTML_WITH_CSS + end + message.add_part(alternative) + message.add_file(filename: "foo.png", content: "foobar") + message.ready_to_send! + message + end + + it "inlines the html part nested inside the alternative" do + expect(deliver(message).html_part.decoded).to include('style="color: red;"') + end + + it "leaves the text part alone" do + expect(deliver(message).text_part.decoded).to include("Lorem ipsum") + expect(deliver(message).text_part.decoded).not_to include("style=") + end + + it "leaves the attachment intact" do + delivered = deliver(message) + + expect(delivered.attachments.size).to eq(1) + expect(delivered.attachments.first.filename).to eq("foo.png") + expect(delivered.attachments.first.decoded).to eq("foobar") + end + end + + # `multipart/related; type="text/html"` contains the string "text/html" + # without being a bare html body. Reading it as one made `decoded` raise + # inside the interceptor and the message never sent. + describe "multipart/related with an inline image" do + subject(:message) do + message = base_message + message.content_type 'multipart/related; type="text/html"; start=""' + message.add_part( + Mail::Part.new do + content_type "text/html; charset=UTF-8" + content_id "" + content_location "/index.html" + body HTML_WITH_CSS + end + ) + message.add_file(filename: "logo.png", content: "img") + message.ready_to_send! + message + end + + it "delivers rather than raising" do + expect { deliver(message) }.not_to raise_error + end + + it "inlines the html part" do + expect(deliver(message).html_part.decoded).to include('style="color: red;"') + end + + it "preserves the html part headers" do + delivered = deliver(message) + + expect(delivered.content_type_parameters["start"]).to eq("") + expect(delivered.html_part.content_id).to eq("") + expect(delivered.html_part.content_location).to eq("/index.html") + end + end + + describe "a text-only message" do + subject(:message) do + message = base_message + message.body "Lorem ipsum" + message.content_type "text/plain; charset=UTF-8" + message.ready_to_send! + message + end + + it "is left untouched" do + expect(deliver(message).decoded).to eq("Lorem ipsum") + end + end + + describe "character encodings" do + def html_message_with(body, charset) + message = base_message + message.body body + message.content_type "text/html; charset=#{charset}" + message.ready_to_send! + message + end + + it "keeps non-latin characters" do + body = HTML_WITH_CSS.sub("Lorem ipsum", "٩(-̮̮̃-̃)۶ 🎉") + + expect(deliver(html_message_with(body, "UTF-8")).decoded).to include("🎉") + end + + it "keeps typographic dashes" do + body = HTML_WITH_CSS.sub("Lorem ipsum", "a—b – c") + + expect(deliver(html_message_with(body, "UTF-8")).decoded).to include("a—b – c") + end + + it "transcodes an ISO-8859-7 body and relabels the charset" do + body = HTML_WITH_CSS.sub("Lorem ipsum", "Αα Ββ Γγ").encode(Encoding::ISO_8859_7) + delivered = deliver(html_message_with(body, "ISO-8859-7")) + + # css_inline always returns UTF-8, so the part must not keep claiming 8859-7. + expect(delivered.content_type).to match(/charset=utf-8/i) + expect(delivered.decoded.force_encoding("UTF-8")).to include("Αα Ββ Γγ") + end + end +end diff --git a/bindings/ruby/rails/spec/css_inline/rails/hook_spec.rb b/bindings/ruby/rails/spec/css_inline/rails/hook_spec.rb new file mode 100644 index 00000000..783ac5b1 --- /dev/null +++ b/bindings/ruby/rails/spec/css_inline/rails/hook_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe CSSInline::Rails::Hook do + it "inlines the body of a pure text/html message" do + message = described_class.delivering_email(html_message) + + expect(message.decoded).to include('style="color: red;"') + expect(message.content_type).to include("text/html") + end + + # The two ways a multipart message gets built differ: the block form caches + # `@html_part`, the `add_part` form (what ActionMailer uses) does not. + [:multipart_message, :multipart_message_via_add_part].each do |builder| + context "a multipart message built with #{builder}" do + it "inlines the html part" do + message = described_class.delivering_email(send(builder)) + + expect(message.html_part.decoded).to include('style="color: red;"') + end + + it "leaves the text part alone" do + message = described_class.delivering_email(send(builder)) + + expect(message.text_part.decoded).to eq("Hello") + end + + it "replaces the html part instead of appending a second one" do + message = described_class.delivering_email(send(builder)) + + expect(message.parts.size).to eq(2) + html_parts = message.parts.select { |p| p.content_type.to_s.include?("text/html") } + expect(html_parts.size).to eq(1) + end + end + end + + it "does nothing to a message with no html part" do + message = Mail.new do + to "to@example.com" + from "from@example.com" + body "just text" + end + + expect { described_class.delivering_email(message) }.not_to raise_error + expect(message.decoded).to eq("just text") + end + + describe "the skip header" do + it "leaves the message uninlined" do + message = html_message + message.header[:skip_css_inline] = true + + described_class.delivering_email(message) + + expect(message.decoded).not_to include("style=") + end + + it "is stripped so it never reaches the recipient" do + message = html_message + message.header[:skip_css_inline] = true + + described_class.delivering_email(message) + + expect(message.header[:skip_css_inline]).to be_nil + end + + [false, "", "0"].each do |value| + it "is stripped without skipping inlining when set to #{value.inspect}" do + message = html_message + message.header[:skip_css_inline] = value + + described_class.delivering_email(message) + + expect(message.header[:skip_css_inline]).to be_nil + expect(message.decoded).to include('style="color: red;"') + end + end + end + + it "resolves a linked stylesheet through the configured strategies" do + CSSInline::Rails.config[:strategies] = [RecordingStrategy.new("h1 { color: blue; }")] + + message = described_class.delivering_email(html_message(body: LINKED_HTML)) + + expect(message.decoded).to include('style="color: blue;"') + end + + # css_inline raises ArgumentError on an href it cannot resolve, so an + # unresolvable link must not reach it. + it "raises FileNotFound when no strategy resolves a linked stylesheet" do + CSSInline::Rails.config[:strategies] = [] + + expect { described_class.delivering_email(html_message(body: LINKED_HTML)) } + .to raise_error(CSSInline::Rails::CSSHelper::FileNotFound, /email-0123456789abcdef\.css/) + end + + it "ignores a link marked data-css-inline=ignore" do + CSSInline::Rails.config[:strategies] = [] + html = LINKED_HTML.sub("") + end + + it "combines configured extra_css with resolved stylesheets" do + described_class.config[:inline_options] = { extra_css: "h1 { font-size: 2px; }" } + described_class.config[:strategies] = [RecordingStrategy.new("h1 { color: blue; }")] + + result = described_class.inline(linked_html("/assets/a.css")) + + expect(result).to include("font-size: 2px;") + expect(result).to include("color: blue;") + end + + # Left on, css_inline raises on an /assets/... href it cannot fetch. + it "forces load_remote_stylesheets off even if configured on" do + described_class.config[:inline_options] = { load_remote_stylesheets: true } + described_class.config[:strategies] = [] + + html = linked_html("/assets/missing.css").sub(" + <%= stylesheet_link_tag "email" %> +

Hello

+ diff --git a/bindings/ruby/rails/spec/spec_helper.rb b/bindings/ruby/rails/spec/spec_helper.rb new file mode 100644 index 00000000..061b1e10 --- /dev/null +++ b/bindings/ruby/rails/spec/spec_helper.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# Before the gem, so the Railtie path is exercised. +require "rails" +require "action_mailer" +require "css_inline/rails" + +require_relative "support/rails_application" +require_relative "support/pipelines" + +RSpec.configure do |config| + config.disable_monkey_patching! + config.order = :random + Kernel.srand config.seed + + config.expect_with(:rspec) { |c| c.syntax = :expect } + + # `double`/`allow`/`receive` are undefined, so no stub can creep back in. + config.mock_with :nothing + + config.before do + CSSInline::Rails.config = CSSInline::Rails.default_config + CSSInline::Rails::CSSHelper.cache = {} + end +end + +SAMPLE_HTML = <<~HTML + + +

Hello

+ +HTML + +def linked_html(href) + <<~HTML + + +

Hello

+ + HTML +end + +LINKED_HTML = linked_html("/assets/email-0123456789abcdef.css") + +def html_message(body: SAMPLE_HTML) + Mail.new do + to "to@example.com" + from "from@example.com" + content_type "text/html; charset=UTF-8" + body body + end +end + +# Caches `@html_part`; a list swap alone is not enough. +def multipart_message(html: SAMPLE_HTML) + Mail.new do + to "to@example.com" + from "from@example.com" + text_part { body "Hello" } + html_part do + content_type "text/html; charset=UTF-8" + body html + end + end +end + +# How ActionMailer assembles one: no cached part. +def multipart_message_via_add_part(html: SAMPLE_HTML) + message = Mail.new do + to "to@example.com" + from "from@example.com" + end + message.add_part(Mail::Part.new { content_type "text/plain"; body "Hello" }) + message.add_part( + Mail::Part.new do + content_type "text/html; charset=UTF-8" + body html + end + ) + message +end diff --git a/bindings/ruby/rails/spec/support/pipelines.rb b/bindings/ruby/rails/spec/support/pipelines.rb new file mode 100644 index 00000000..9a4f9a1a --- /dev/null +++ b/bindings/ruby/rails/spec/support/pipelines.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "tmpdir" +require "fileutils" +require "active_support/all" +require "propshaft" +require "sprockets" +require "sprockets/manifest" + +# Real Propshaft and Sprockets objects over a throwaway asset directory, so a +# change in either breaks these specs. +module Pipelines + CSS = "h1 { color: blue; }\n" + + def with_propshaft(css: CSS, prefix: "/assets") + Dir.mktmpdir do |dir| + source = File.join(dir, "app/assets/stylesheets") + FileUtils.mkdir_p(source) + File.write(File.join(source, "email.css"), css) + + config = ActiveSupport::OrderedOptions.new + config.paths = [source] + config.version = "1" + config.prefix = prefix + config.output_path = Pathname.new(File.join(dir, "public", prefix)) + config.manifest_path = Pathname.new(File.join(dir, "public", prefix, ".manifest.json")) + config.compilers = [["text/css", Propshaft::Compiler::CssAssetUrls]] + config.file_watcher = nil + config.integrity_hash_algorithm = nil + + assembly = Propshaft::Assembly.new(config) + # Asked of Propshaft, not hardcoded, so the spec tracks its digest format. + digested = assembly.load_path.find("email.css").digested_path + yield assembly, File.join(prefix, digested.to_s) + end + end + + def with_sprockets(css: CSS, prefix: "/assets") + Dir.mktmpdir do |dir| + source = File.join(dir, "app/assets/stylesheets") + FileUtils.mkdir_p(source) + File.write(File.join(source, "email.css"), css) + + environment = Sprockets::Environment.new(dir) + environment.append_path(source) + manifest = Sprockets::Manifest.new(environment, File.join(dir, "public", prefix)) + digested = environment["email.css"].digest_path + + yield manifest, File.join(prefix, digested.to_s) + end + end +end + +# The documented strategy contract: `#load(url)` returning CSS or nil. +class RecordingStrategy + attr_reader :urls + + def initialize(css = nil) + @css = css + @urls = [] + end + + def load(url) + @urls << url + @css.respond_to?(:call) ? @css.call(url) : @css + end +end + +RSpec.configure { |config| config.include Pipelines } diff --git a/bindings/ruby/rails/spec/support/rails_application.rb b/bindings/ruby/rails/spec/support/rails_application.rb new file mode 100644 index 00000000..4538ebb6 --- /dev/null +++ b/bindings/ruby/rails/spec/support/rails_application.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "rails" +require "active_support/all" +# Each reopens Rails::Application with an accessor the loaders read: `assets` +# from Propshaft, `assets_manifest` from sprockets-rails. +require "propshaft" +require "sprockets/railtie" + +# A real, un-booted Rails::Application. Every accessor used is public Rails API, +# so nothing here is stubbed. +module RailsApplication + APP = Class.new(::Rails::Application).instance + ORIGINAL_ROOT = APP.config.root + + # Clear what defining the class set, so examples start with no application. + ::Rails.app_class = nil + ::Rails.application = nil + + def use_rails_app(assets: nil, assets_manifest: nil, prefix: "/assets", + relative_url_root: nil, root: nil) + APP.assets = assets + APP.assets_manifest = assets_manifest + APP.config.root = root if root + APP.config.assets.prefix = prefix + APP.config.relative_url_root = relative_url_root + ::Rails.application = APP + APP + end +end + +RSpec.configure do |config| + config.include RailsApplication + + # `config.assets` is shared railtie state, so changes must be undone. + config.around do |example| + original_env = ::Rails.env.to_s + example.run + ensure + ::Rails.application = nil + ::Rails.app_class = nil + ::Rails.env = original_env + RailsApplication::APP.assets = nil + RailsApplication::APP.assets_manifest = nil + RailsApplication::APP.config.root = RailsApplication::ORIGINAL_ROOT + RailsApplication::APP.config.assets.prefix = "/assets" + RailsApplication::APP.config.relative_url_root = nil + end +end