diff --git a/Gemfile.lock b/Gemfile.lock
index 9ff747d..0ed5600 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
- fileboost (0.1.5)
+ fileboost (0.2.0.pre2)
activestorage (>= 6.0)
GEM
diff --git a/README.md b/README.md
index 0fc17e8..6912206 100644
--- a/README.md
+++ b/README.md
@@ -4,13 +4,39 @@
Fileboost is a Rails gem that provides seamless integration with the Fileboost.dev image optimization service. It offers drop-in replacement helpers for Rails' native image helpers with automatic optimization, HMAC authentication, and comprehensive transformation support for ActiveStorage objects.
+## Table of Contents
+
+- [Features](#features)
+- [Installation](#installation)
+- [Configuration](#configuration)
+- [Usage](#usage)
+ - [Drop-in Replacement (Recommended)](#drop-in-replacement-recommended)
+ - [Manual Helper Method](#manual-helper-method)
+ - [URL Generation](#url-generation)
+ - [Transformation Options](#transformation-options)
+ - [Parameter Aliases](#parameter-aliases)
+ - [ActiveStorage Support](#activestorage-support)
+ - [ActiveStorage Variants (NEW in v0.2.0)](#activestorage-variants-new-in-v020)
+ - [Variant Transformation Mapping](#variant-transformation-mapping)
+ - [Combining Variants with Custom Options](#combining-variants-with-custom-options)
+ - [Responsive Images](#responsive-images)
+- [Error Handling](#error-handling)
+- [Security](#security)
+- [Development](#development)
+- [Testing](#testing)
+- [Contributing](#contributing)
+- [License](#license)
+- [Support](#support)
+
## Features
-- 🚀 **Drop-in replacement** for Rails `image_tag` and `url_for` helpers
+- 🚀 **Drop-in replacement** for Rails `image_tag` with zero code changes (NEW in v0.2.0)
+- 🎨 **Full ActiveStorage Variant support** with automatic transformation mapping (NEW in v0.2.0)
- 🔒 **Secure HMAC authentication** with Fileboost.dev service
- 📱 **ActiveStorage only** - works exclusively with ActiveStorage attachments
- 🎛️ **Comprehensive transformations** - resize, quality, format conversion, and more
- 🔧 **Simple configuration** - just project ID and token required
+- 🔄 **Automatic fallback** - non-ActiveStorage images work exactly as before
## Installation
@@ -43,11 +69,59 @@ export FILEBOOST_PROJECT_ID="your-project-id"
export FILEBOOST_TOKEN="your-secret-token"
```
+Or configure directly in your initializer:
+
+```ruby
+# config/initializers/fileboost.rb
+Fileboost.configure do |config|
+ config.project_id = ENV["FILEBOOST_PROJECT_ID"]
+ config.token = ENV["FILEBOOST_TOKEN"]
+
+ # Optional: Enable drop-in replacement for Rails image_tag (default: false)
+ config.patch_image_tag = true
+end
+```
+
## Usage
-### Basic Image Tag
+### Drop-in Replacement (Recommended)
+
+Enable `patch_image_tag` in your configuration to automatically optimize ActiveStorage images with your existing `image_tag` calls:
+
+```ruby
+# config/initializers/fileboost.rb
+Fileboost.configure do |config|
+ config.project_id = ENV["FILEBOOST_PROJECT_ID"]
+ config.token = ENV["FILEBOOST_TOKEN"]
+ config.patch_image_tag = true # Enable automatic optimization
+end
+```
-Replace `image_tag` with `fileboost_image_tag` for ActiveStorage objects:
+With this enabled, your existing Rails code automatically gets Fileboost optimization:
+
+```erb
+
+<%= image_tag user.avatar, resize: { w: 300, h: 300 }, alt: "Avatar" %>
+<%= image_tag post.featured_image, resize: { width: 800, quality: 85 }, class: "hero" %>
+
+
+<%= image_tag user.avatar.variant(resize_to_limit: [100, 100]), alt: "Thumbnail" %>
+<%= image_tag post.image.variant(:thumb), alt: "Post thumbnail" %>
+
+
+<%= image_tag "/assets/logo.png", alt: "Logo" %>
+<%= image_tag "https://example.com/image.jpg", alt: "External" %>
+```
+
+**Benefits:**
+- Zero code changes required for existing ActiveStorage images
+- Full ActiveStorage variant support with automatic transformation mapping
+- Automatic fallback to Rails behavior for non-ActiveStorage assets
+- Gradual migration path - enable/disable with single configuration option
+
+### Manual Helper Method
+
+Alternatively, use `fileboost_image_tag` explicitly for ActiveStorage objects:
```erb
@@ -104,7 +178,7 @@ fileboost_image_tag(image, resize: { w: 400, h: 300, q: 85 })
fileboost_image_tag(image, resize: { width: 400, height: 300, quality: 85 })
```
-**Note:** Avoid using the `format` parameter. Fileboost automatically selects the optimal image format (WebP, AVIF, JPEG, etc.) based on browser headers and capabilities for the best performance and compatibility.
+**🎯 Smart Optimization:** Fileboost's CDN automatically detects and delivers the optimal image format (WebP, AVIF, JPEG, etc.) based on browser capabilities, device type, and connection speed for maximum performance.
### ActiveStorage Support
@@ -123,6 +197,63 @@ Works seamlessly with all ActiveStorage attachment types:
<%= fileboost_image_tag post.featured_image.blob, resize: { w: 800 } %>
```
+### ActiveStorage Variants (NEW in v0.2.0)
+
+Fileboost now provides full support for ActiveStorage variants with automatic transformation mapping:
+
+```erb
+
+<%= image_tag user.avatar.variant(resize_to_limit: [200, 200]) %>
+
+
+<%= image_tag post.image.variant(resize_to_fit: [400, 300]) %>
+
+
+<%= image_tag hero.banner.variant(resize_to_fill: [800, 400]) %>
+
+
+
+<%= image_tag post.image.variant(
+ resize_to_limit: [600, 400],
+ quality: 85
+) %>
+
+
+
+<%= image_tag user.avatar.variant(:thumb) %>
+
+```
+
+#### Variant Transformation Mapping
+
+Fileboost automatically maps ActiveStorage variant transformations to optimized URL parameters:
+
+| ActiveStorage Variant | Fileboost Parameters | Description |
+|----------------------|---------------------|-------------|
+| `resize_to_limit: [w, h]` | `w=W&h=H&fit=scale-down` | Resize within bounds, preserving aspect ratio |
+| `resize_to_fit: [w, h]` | `w=W&h=H&fit=contain` | Resize to fit exactly, with letterboxing if needed |
+| `resize_to_fill: [w, h]` | `w=W&h=H&fit=cover` | Resize and crop to fill exactly |
+| `resize_and_pad: [w, h]` | `w=W&h=H&fit=pad` | Resize with padding |
+| `quality: 85` | `q=85` | JPEG/WebP quality (1-100) |
+| `rotate: "-90"` | `r=-90` | Rotation in degrees |
+
+
+#### Combining Variants with Custom Options
+
+You can combine variant transformations with additional Fileboost options:
+
+```erb
+
+<%= image_tag user.avatar.variant(resize_to_limit: [200, 200]),
+ resize: { blur: 5, brightness: 110 } %>
+
+
+
+<%= image_tag post.image.variant(resize_to_limit: [400, 300]),
+ resize: { w: 500 } %>
+
+```
+
### Responsive Images
Generate multiple sizes for responsive designs:
@@ -171,7 +302,7 @@ After checking out the repo, run:
```bash
$ bundle install
-$ rake test
+$ bundle exec rspec
```
To test against the dummy Rails application:
@@ -186,7 +317,7 @@ $ rails server
Run the test suite:
```bash
-$ rake test
+$ bundle exec rspec
```
Run RuboCop:
diff --git a/lib/fileboost.rb b/lib/fileboost.rb
index af508a0..c3da1c2 100644
--- a/lib/fileboost.rb
+++ b/lib/fileboost.rb
@@ -2,8 +2,10 @@
require "fileboost/config"
require "fileboost/error_handler"
require "fileboost/signature_generator"
+require "fileboost/variant_transformer"
require "fileboost/url_builder"
require "fileboost/helpers"
+require "fileboost/image_tag_patch"
require "fileboost/engine"
module Fileboost
diff --git a/lib/fileboost/config.rb b/lib/fileboost/config.rb
index e76dce4..5e40ea7 100644
--- a/lib/fileboost/config.rb
+++ b/lib/fileboost/config.rb
@@ -1,6 +1,6 @@
module Fileboost
class Config
- attr_accessor :project_id, :token
+ attr_accessor :project_id, :token, :patch_image_tag
CDN_DOMAIN = "cdn.fileboost.dev"
BASE_URL = "https://#{CDN_DOMAIN}"
@@ -8,6 +8,7 @@ class Config
def initialize
@project_id = ENV["FILEBOOST_PROJECT_ID"]
@token = ENV["FILEBOOST_TOKEN"]
+ @patch_image_tag = false
end
def valid?
diff --git a/lib/fileboost/engine.rb b/lib/fileboost/engine.rb
index 32adc79..2e1867a 100644
--- a/lib/fileboost/engine.rb
+++ b/lib/fileboost/engine.rb
@@ -5,6 +5,11 @@ class Engine < ::Rails::Engine
initializer "fileboost.action_view" do
ActiveSupport.on_load :action_view do
include Fileboost::Helpers
+
+ # Conditionally patch image_tag if enabled in configuration
+ if Fileboost.config.patch_image_tag
+ prepend Fileboost::ImageTagPatch
+ end
end
end
end
diff --git a/lib/fileboost/image_tag_patch.rb b/lib/fileboost/image_tag_patch.rb
new file mode 100644
index 0000000..ffcf593
--- /dev/null
+++ b/lib/fileboost/image_tag_patch.rb
@@ -0,0 +1,26 @@
+module Fileboost
+ module ImageTagPatch
+ def image_tag(source, options = {})
+ # If this is an ActiveStorage asset and Fileboost is configured, use fileboost_image_tag
+ if valid_activestorage_asset?(source) && Fileboost.config.valid?
+ fileboost_image_tag(source, **options)
+ else
+ # Fall back to Rails' original image_tag for all other cases
+ super(source, options)
+ end
+ rescue
+ # If there's any error with Fileboost processing, fall back to original Rails behavior
+ super(source, options)
+ end
+
+ private
+
+ def valid_activestorage_asset?(asset)
+ return true if asset.is_a?(ActiveStorage::Blob)
+ return true if asset.is_a?(ActiveStorage::Attached)
+ return true if asset.is_a?(ActiveStorage::VariantWithRecord)
+
+ false
+ end
+ end
+end
diff --git a/lib/fileboost/url_builder.rb b/lib/fileboost/url_builder.rb
index 188e4ef..f2b6e59 100644
--- a/lib/fileboost/url_builder.rb
+++ b/lib/fileboost/url_builder.rb
@@ -43,7 +43,7 @@ def self.build_url(asset, **options)
full_path = "/#{project_id}#{asset_path}"
# Extract and normalize transformation parameters
- transformation_params = extract_transformation_params(options)
+ transformation_params = extract_transformation_params(asset, options)
# Generate HMAC signature for secure authentication
signature = Fileboost::SignatureGenerator.generate(
@@ -89,10 +89,16 @@ def self.extract_asset_path(asset)
end
end
- def self.extract_transformation_params(options)
+ def self.extract_transformation_params(asset, options)
params = {}
- # Only handle nested resize parameter
+ # First, extract variant transformations if this is a variant
+ if asset.is_a?(ActiveStorage::VariantWithRecord)
+ variant_params = Fileboost::VariantTransformer.transform_variant_params(asset)
+ params.merge!(variant_params)
+ end
+
+ # Then handle explicit resize parameter (this can override variant params)
if options[:resize].is_a?(Hash)
resize_options = options[:resize]
resize_options.each do |key, value|
@@ -117,9 +123,13 @@ def self.extract_transformation_params(options)
def self.normalize_param_value(key, value)
case key
- when "w", "h", "q", "b", "br", "c", "r"
+ when "w", "h", "b", "br", "c", "r"
# Numeric parameters
value.to_i.to_s if value.to_i > 0
+ when "q"
+ # Quality parameter - validate range 1-100
+ q = value.to_i
+ (q > 0 && q <= 100) ? q.to_s : nil
when "f"
# Format parameter - validate against common formats
valid_formats = %w[webp jpeg jpg png gif avif]
diff --git a/lib/fileboost/variant_transformer.rb b/lib/fileboost/variant_transformer.rb
new file mode 100644
index 0000000..0ab4012
--- /dev/null
+++ b/lib/fileboost/variant_transformer.rb
@@ -0,0 +1,130 @@
+module Fileboost
+ # Maps ActiveStorage variant transformations to Fileboost URL parameters
+ class VariantTransformer
+ # Maps ActiveStorage transformation operations to Fileboost parameters
+ TRANSFORMATION_MAPPING = {
+ # Resize operations
+ resize_to_limit: { fit: "scale-down" },
+ resize_to_fit: { fit: "contain" },
+ resize_to_fill: { fit: "cover" },
+ resize_and_pad: { fit: "pad" },
+
+ # Quality settings
+ quality: { param: "q" },
+
+ # Format settings
+ format: { param: "f" },
+
+ # Rotation
+ rotate: { param: "r" },
+
+ # Crop operations - need special handling
+ crop: { special: :crop_handler }
+ }.freeze
+
+ # Convert ActiveStorage variant transformations to Fileboost parameters
+ def self.transform_variant_params(variant)
+ return {} unless variant.respond_to?(:variation)
+
+ transformations = variant.variation.transformations
+ params = {}
+
+ transformations.each do |operation, value|
+ case operation
+ when :resize_to_limit, :resize_to_fit, :resize_to_fill, :resize_and_pad
+ resize_params = handle_resize_operation(operation, value)
+ params.merge!(resize_params)
+
+ when :quality
+ params["q"] = normalize_quality(value)
+
+ when :format
+ params["f"] = normalize_format(value)
+
+ when :rotate
+ params["r"] = normalize_rotation(value)
+
+ when :crop
+ crop_params = handle_crop_operation(value)
+ params.merge!(crop_params) if crop_params
+
+ # Add more transformations as needed
+ end
+ end
+
+ params
+ end
+
+ private
+
+ # Handle resize operations (resize_to_limit, resize_to_fit, etc.)
+ def self.handle_resize_operation(operation, dimensions)
+ return {} unless dimensions.is_a?(Array) && dimensions.length >= 2
+
+ width, height = dimensions[0], dimensions[1]
+ params = {}
+
+ # Set dimensions
+ params["w"] = width.to_s if width && width > 0
+ params["h"] = height.to_s if height && height > 0
+
+ # Set fit parameter based on resize operation
+ fit_mapping = TRANSFORMATION_MAPPING[operation]
+ params["fit"] = fit_mapping[:fit] if fit_mapping && fit_mapping[:fit]
+
+ params
+ end
+
+ # Handle crop operations
+ def self.handle_crop_operation(crop_params)
+ # Crop can be in different formats depending on the processor
+ # For now, we'll handle simple array format: [x, y, width, height]
+ if crop_params.is_a?(Array) && crop_params.length == 4
+ x, y, w, h = crop_params
+ return { "crop" => "#{x},#{y},#{w},#{h}" }
+ end
+
+ # Could extend this for other crop formats
+ nil
+ end
+
+ # Normalize quality value (0-100)
+ def self.normalize_quality(quality)
+ q = quality.to_i
+ return nil if q <= 0 || q > 100
+ q.to_s
+ end
+
+ # Normalize format value
+ def self.normalize_format(format)
+ # Convert to string and lowercase
+ format_str = format.to_s.downcase
+
+ # Map common format variations
+ case format_str
+ when "jpg", "jpeg"
+ "jpg"
+ when "png"
+ "png"
+ when "webp"
+ "webp"
+ when "avif"
+ "avif"
+ when "gif"
+ "gif"
+ else
+ # If it's already a recognized format, use it
+ valid_formats = %w[webp jpeg jpg png gif avif]
+ valid_formats.include?(format_str) ? format_str : nil
+ end
+ end
+
+ # Normalize rotation value
+ def self.normalize_rotation(rotation)
+ # Rotation should be a number (degrees)
+ r = rotation.to_s.gsub(/[^\d\-]/, "") # Remove non-numeric chars except minus
+ return nil if r.empty?
+ r
+ end
+ end
+end
diff --git a/lib/fileboost/version.rb b/lib/fileboost/version.rb
index 95ba749..626d2b8 100644
--- a/lib/fileboost/version.rb
+++ b/lib/fileboost/version.rb
@@ -1,3 +1,3 @@
module Fileboost
- VERSION = "0.1.5"
+ VERSION = "0.2.0.pre2"
end
diff --git a/lib/generators/fileboost/templates/INSTALL.md b/lib/generators/fileboost/templates/INSTALL.md
index 6aeb06f..163433e 100644
--- a/lib/generators/fileboost/templates/INSTALL.md
+++ b/lib/generators/fileboost/templates/INSTALL.md
@@ -9,33 +9,24 @@ config/initializers/fileboost.rb
Next steps:
-1. Set your environment variables:
+1. Register an account at: https://fileboost.dev
+ Get your project ID and secret token
+
+2. Set your environment variables:
export FILEBOOST_PROJECT_ID="your-project-id"
export FILEBOOST_TOKEN="your-secret-token"
-2. Or configure directly in the initializer file:
- Edit config/initializers/fileboost.rb with your credentials
-
-3. Start using Fileboost helpers in your views:
-
-
-
- <%= fileboost_image_tag user.avatar, alt: "Avatar", resize: {width: 100, height: 100, fit: "cover"} %>
-
-
+3. Enable drop-in replacement (recommended):
+ Edit config/initializers/fileboost.rb and set:
+ config.patch_image_tag = true
- <%= fileboost_url_for post.image %>
+4. Your existing image_tag calls now work with ActiveStorage images:
+ <%= image_tag user.avatar %>
+ <%= image_tag post.image.variant(resize_to_limit: [300, 200]) %>
-4. Supported transformation options:
- - width, height (or w, h)
- - quality (or q): 1-100
- - format (or f): webp, jpeg, png, gif, avif
- - blur (or b): 0-100
- - brightness (or br): 0-200
- - contrast (or c): 0-200
- - rotation (or r): 0-359
- - fit: cover, contain, fill, scale-down, crop, pad
+5. Or use explicit helpers:
+ <%= fileboost_image_tag user.avatar, resize: {w: 300, h: 200, fit: "cover"} %>
-For more information, visit: https://fileboost.dev
+For documentation, visit: https://github.com/bilalbudhani/fileboost
===============================================================================
diff --git a/lib/generators/fileboost/templates/fileboost.rb b/lib/generators/fileboost/templates/fileboost.rb
index 6ce01ba..b53ec5e 100644
--- a/lib/generators/fileboost/templates/fileboost.rb
+++ b/lib/generators/fileboost/templates/fileboost.rb
@@ -16,4 +16,10 @@
# You can also set this via the FILEBOOST_TOKEN environment variable
# IMPORTANT: Keep this secret secure and never commit it to version control
config.token = ENV["FILEBOOST_TOKEN"] # || "your-secret-token"
+
+ # Drop-in replacement for Rails image_tag helper
+ # When enabled, image_tag will automatically use Fileboost optimization
+ # for ActiveStorage assets while falling back to standard Rails behavior
+ # for other image sources. Defaults to false.
+ # config.patch_image_tag = true
end
diff --git a/spec/fileboost/config_spec.rb b/spec/fileboost/config_spec.rb
index 2d262f0..e702d68 100644
--- a/spec/fileboost/config_spec.rb
+++ b/spec/fileboost/config_spec.rb
@@ -10,10 +10,17 @@
expect(config.project_id).to eq("test_project")
expect(config.token).to eq("test_token")
+ expect(config.patch_image_tag).to eq(false)
ENV.delete("FILEBOOST_PROJECT_ID")
ENV.delete("FILEBOOST_TOKEN")
end
+
+ it "sets patch_image_tag to false by default" do
+ config = Fileboost::Config.new
+
+ expect(config.patch_image_tag).to eq(false)
+ end
end
describe "#valid?" do
diff --git a/spec/fileboost/helpers_spec.rb b/spec/fileboost/helpers_spec.rb
index d168002..15c729b 100644
--- a/spec/fileboost/helpers_spec.rb
+++ b/spec/fileboost/helpers_spec.rb
@@ -37,6 +37,29 @@
expect(url).to include("w=300")
end
+ it "generates URL for ActiveStorage variant" do
+ variant = blob.variant(resize_to_limit: [ 100, 100 ])
+
+ url = fileboost_url_for(variant)
+
+ expect(url).to include("https://cdn.fileboost.dev")
+ expect(url).to include("w=100")
+ expect(url).to include("h=100")
+ expect(url).to include("fit=scale-down")
+ end
+
+ it "generates URL for named variants" do
+ user.avatar.attach(blob)
+ thumb_variant = user.avatar.variant(:thumb)
+
+ url = fileboost_url_for(thumb_variant)
+
+ expect(url).to include("https://cdn.fileboost.dev")
+ expect(url).to include("w=100")
+ expect(url).to include("h=100")
+ expect(url).to include("fit=scale-down")
+ end
+
it "raises ArgumentError for invalid asset type" do
expect {
fileboost_url_for("invalid", resize: { w: 300 })
@@ -60,6 +83,25 @@
expect(result).to eq('
')
end
+
+ it "generates image tag for variants" do
+ variant = blob.variant(resize_to_limit: [ 100, 100 ])
+ allow(self).to receive(:image_tag).and_return('
')
+
+ result = fileboost_image_tag(variant, alt: "test")
+
+ expect(result).to eq('
')
+ end
+
+ it "generates image tag for named variants" do
+ user.avatar.attach(blob)
+ thumb_variant = user.avatar.variant(:thumb)
+ allow(self).to receive(:image_tag).and_return('
')
+
+ result = fileboost_image_tag(thumb_variant, alt: "test")
+
+ expect(result).to eq('
')
+ end
end
describe "#fileboost_responsive_urls" do
diff --git a/spec/fileboost/image_tag_patch_spec.rb b/spec/fileboost/image_tag_patch_spec.rb
new file mode 100644
index 0000000..151b7ea
--- /dev/null
+++ b/spec/fileboost/image_tag_patch_spec.rb
@@ -0,0 +1,150 @@
+require "spec_helper"
+
+RSpec.describe Fileboost::ImageTagPatch do
+ # Create a test class that includes both helpers and patch
+ let(:view_class) do
+ Class.new(ActionView::Base) do
+ include Fileboost::Helpers
+ prepend Fileboost::ImageTagPatch
+ end
+ end
+ let(:view_instance) { view_class.new(ActionView::LookupContext.new([]), {}, nil) }
+
+ let(:user) { User.create!(name: "Test User") }
+ let(:blob) do
+ ActiveStorage::Blob.create_and_upload!(
+ io: StringIO.new("fake image data"),
+ filename: "test.jpg",
+ content_type: "image/jpeg"
+ )
+ end
+
+ before do
+ Fileboost.configure do |config|
+ config.project_id = "test_project"
+ config.token = "test_token"
+ config.patch_image_tag = true
+ end
+ end
+
+ describe "#image_tag with patch enabled" do
+ context "with ActiveStorage assets" do
+ it "uses fileboost_image_tag for ActiveStorage::Blob" do
+ expect(view_instance).to receive(:fileboost_image_tag).with(blob, alt: "test").and_return('
')
+
+ result = view_instance.image_tag(blob, alt: "test")
+
+ expect(result).to eq('
')
+ end
+
+ it "uses fileboost_image_tag for ActiveStorage::Attached" do
+ user.avatar.attach(blob)
+ expect(view_instance).to receive(:fileboost_image_tag).with(user.avatar, alt: "avatar").and_return('
')
+
+ result = view_instance.image_tag(user.avatar, alt: "avatar")
+
+ expect(result).to eq('
')
+ end
+
+ it "uses fileboost_image_tag for ActiveStorage::VariantWithRecord" do
+ variant = blob.variant(resize_to_limit: [ 100, 100 ])
+ expect(view_instance).to receive(:fileboost_image_tag).with(variant, alt: "variant").and_return('
')
+
+ result = view_instance.image_tag(variant, alt: "variant")
+
+ expect(result).to eq('
')
+ end
+
+ it "uses fileboost_image_tag for named variants" do
+ user.avatar.attach(blob)
+ thumb_variant = user.avatar.variant(:thumb)
+ expect(view_instance).to receive(:fileboost_image_tag).with(thumb_variant, alt: "thumb").and_return('
')
+
+ result = view_instance.image_tag(thumb_variant, alt: "thumb")
+
+ expect(result).to eq('
')
+ end
+
+ it "passes through all options to fileboost_image_tag" do
+ options = { alt: "test", class: "hero-image", data: { id: "123" }, resize: { w: 300 } }
+ expect(view_instance).to receive(:fileboost_image_tag).with(blob, **options).and_return('
')
+
+ view_instance.image_tag(blob, **options)
+ end
+ end
+
+ context "with non-ActiveStorage assets" do
+ it "uses original Rails image_tag for string paths" do
+ result = view_instance.image_tag("/path/to/image.jpg", alt: "test")
+
+ # Should call Rails' original image_tag which creates a standard img tag
+ expect(result).to include('src="/path/to/image.jpg"')
+ expect(result).to include('alt="test"')
+ end
+
+ it "uses original Rails image_tag for URLs" do
+ result = view_instance.image_tag("https://example.com/image.jpg", alt: "external")
+
+ expect(result).to include('src="https://example.com/image.jpg"')
+ expect(result).to include('alt="external"')
+ end
+ end
+
+ context "configuration validation" do
+ it "does not call fileboost_image_tag when config is invalid" do
+ Fileboost.configure do |config|
+ config.project_id = ""
+ config.token = "test_token"
+ config.patch_image_tag = true
+ end
+
+ expect(view_instance).not_to receive(:fileboost_image_tag)
+
+ # Test that the method correctly skips fileboost processing
+ # We don't test the actual super() call since it requires complex ActionView setup
+ expect(view_instance.send(:valid_activestorage_asset?, blob)).to be true
+ expect(Fileboost.config.valid?).to be false
+ end
+ end
+ end
+
+ describe "configuration behavior" do
+ context "when patch_image_tag is false" do
+ let(:unpatch_view_class) do
+ Class.new(ActionView::Base) do
+ include Fileboost::Helpers
+ # Do not include the patch module
+ end
+ end
+ let(:unpatch_view_instance) { unpatch_view_class.new(ActionView::LookupContext.new([]), {}, nil) }
+
+ it "does not interfere with original image_tag behavior" do
+ # Since patch is not included, this should work as normal Rails image_tag
+ result = unpatch_view_instance.image_tag("/normal/image.jpg", alt: "normal")
+
+ expect(result).to include('src="/normal/image.jpg"')
+ expect(result).to include('alt="normal"')
+ end
+ end
+ end
+
+ describe "#valid_activestorage_asset?" do
+ it "returns true for ActiveStorage::Blob" do
+ expect(view_instance.send(:valid_activestorage_asset?, blob)).to be true
+ end
+
+ it "returns true for ActiveStorage::Attached" do
+ user.avatar.attach(blob)
+ expect(view_instance.send(:valid_activestorage_asset?, user.avatar)).to be true
+ end
+
+ it "returns false for strings" do
+ expect(view_instance.send(:valid_activestorage_asset?, "/path/to/image.jpg")).to be false
+ end
+
+ it "returns false for other objects" do
+ expect(view_instance.send(:valid_activestorage_asset?, { url: "test" })).to be false
+ expect(view_instance.send(:valid_activestorage_asset?, 123)).to be false
+ end
+ end
+end
diff --git a/spec/fileboost/url_builder_spec.rb b/spec/fileboost/url_builder_spec.rb
index 44c7fb5..fe10f59 100644
--- a/spec/fileboost/url_builder_spec.rb
+++ b/spec/fileboost/url_builder_spec.rb
@@ -10,6 +10,10 @@
)
end
+ let(:variant) do
+ blob.variant(resize_to_limit: [ 300, 200 ])
+ end
+
before do
Fileboost.configure do |config|
config.project_id = "test_project"
@@ -17,6 +21,10 @@
end
end
+ after do
+ blob.purge
+ end
+
describe ".build_url" do
context "with valid ActiveStorage::Blob" do
it "builds a URL with signature" do
@@ -42,6 +50,53 @@
end
end
+ context "with ActiveStorage variant" do
+ it "builds a URL with variant transformations" do
+ variant = blob.variant(resize_to_limit: [ 100, 100 ])
+
+ url = Fileboost::UrlBuilder.build_url(variant)
+
+ expect(url).to include("https://cdn.fileboost.dev")
+ expect(url).to include("w=100")
+ expect(url).to include("h=100")
+ expect(url).to include("fit=scale-down")
+ expect(url).to include("sig=")
+ end
+
+ it "merges variant transformations with explicit resize options" do
+ variant = blob.variant(resize_to_limit: [ 100, 100 ])
+
+ url = Fileboost::UrlBuilder.build_url(variant, resize: { q: 85 })
+
+ expect(url).to include("w=100")
+ expect(url).to include("h=100")
+ expect(url).to include("fit=scale-down")
+ expect(url).to include("q=85")
+ end
+
+ it "allows explicit resize options to override variant parameters" do
+ variant = blob.variant(resize_to_limit: [ 100, 100 ])
+
+ url = Fileboost::UrlBuilder.build_url(variant, resize: { w: 200 })
+
+ expect(url).to include("w=200") # overridden
+ expect(url).to include("h=100") # from variant
+ expect(url).to include("fit=scale-down") # from variant
+ end
+
+ it "builds a URL for named variants" do
+ user.avatar.attach(blob)
+ thumb_variant = user.avatar.variant(:thumb)
+
+ url = Fileboost::UrlBuilder.build_url(thumb_variant)
+
+ expect(url).to include("https://cdn.fileboost.dev")
+ expect(url).to include("w=100")
+ expect(url).to include("h=100")
+ expect(url).to include("fit=scale-down")
+ end
+ end
+
context "with invalid configuration" do
it "raises ConfigurationError when config is invalid" do
Fileboost.config.project_id = ""
@@ -59,46 +114,41 @@
}.to raise_error(Fileboost::AssetPathExtractionError)
end
end
- end
- describe ".extract_transformation_params" do
- it "extracts and normalizes resize parameters" do
- options = { resize: { width: 300, height: 200, quality: 85, format: "webp" } }
+ context "with parameter normalization" do
+ it "normalizes resize parameters correctly" do
+ url = Fileboost::UrlBuilder.build_url(blob, resize: { width: 300, height: 200, quality: 85, format: "webp" })
- params = Fileboost::UrlBuilder.send(:extract_transformation_params, options)
+ expect(url).to include("w=300")
+ expect(url).to include("h=200")
+ expect(url).to include("q=85")
+ expect(url).to include("f=webp")
+ end
- expect(params["w"]).to eq("300")
- expect(params["h"]).to eq("200")
- expect(params["q"]).to eq("85")
- expect(params["f"]).to eq("webp")
- end
+ it "ignores invalid parameters" do
+ url = Fileboost::UrlBuilder.build_url(blob, resize: { invalid_param: "value", w: 300 })
- it "ignores invalid parameters" do
- options = { resize: { invalid_param: "value", w: 300 } }
+ expect(url).to include("w=300")
+ expect(url).not_to include("invalid_param")
+ end
- params = Fileboost::UrlBuilder.send(:extract_transformation_params, options)
+ it "rejects invalid quality values" do
+ url = Fileboost::UrlBuilder.build_url(blob, resize: { q: 150 })
- expect(params).not_to have_key("invalid_param")
- expect(params["w"]).to eq("300")
- end
- end
+ expect(url).not_to include("q=150")
+ end
- describe ".normalize_param_value" do
- it "normalizes numeric parameters" do
- expect(Fileboost::UrlBuilder.send(:normalize_param_value, "w", 300)).to eq("300")
- expect(Fileboost::UrlBuilder.send(:normalize_param_value, "q", "85")).to eq("85")
- expect(Fileboost::UrlBuilder.send(:normalize_param_value, "w", 0)).to be_nil
- end
+ it "rejects invalid format values" do
+ url = Fileboost::UrlBuilder.build_url(blob, resize: { f: "invalid" })
- it "validates format parameter" do
- expect(Fileboost::UrlBuilder.send(:normalize_param_value, "f", "webp")).to eq("webp")
- expect(Fileboost::UrlBuilder.send(:normalize_param_value, "f", "invalid")).to be_nil
- end
+ expect(url).not_to include("f=invalid")
+ end
- it "validates fit parameter" do
- expect(Fileboost::UrlBuilder.send(:normalize_param_value, "fit", "cover")).to eq("cover")
- expect(Fileboost::UrlBuilder.send(:normalize_param_value, "fit", "scale_down")).to eq("scale-down")
- expect(Fileboost::UrlBuilder.send(:normalize_param_value, "fit", "invalid")).to be_nil
+ it "normalizes fit parameter correctly" do
+ url = Fileboost::UrlBuilder.build_url(blob, resize: { fit: "scale_down" })
+
+ expect(url).to include("fit=scale-down")
+ end
end
end
end
diff --git a/spec/fileboost/variant_transformer_spec.rb b/spec/fileboost/variant_transformer_spec.rb
new file mode 100644
index 0000000..3fe6648
--- /dev/null
+++ b/spec/fileboost/variant_transformer_spec.rb
@@ -0,0 +1,199 @@
+require "spec_helper"
+
+RSpec.describe Fileboost::VariantTransformer do
+ let(:user) { User.create!(name: "Test User") }
+ let(:blob) do
+ ActiveStorage::Blob.create_and_upload!(
+ io: StringIO.new("fake image data"),
+ filename: "test.jpg",
+ content_type: "image/jpeg"
+ )
+ end
+
+ after do
+ blob.purge
+ end
+
+ describe ".transform_variant_params" do
+ context "with resize_to_limit" do
+ it "transforms resize_to_limit to w, h, and fit=scale-down" do
+ variant = blob.variant(resize_to_limit: [ 100, 100 ])
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["w"]).to eq("100")
+ expect(params["h"]).to eq("100")
+ expect(params["fit"]).to eq("scale-down")
+ end
+ end
+
+ context "with resize_to_fit" do
+ it "transforms resize_to_fit to w, h, and fit=contain" do
+ variant = blob.variant(resize_to_fit: [ 200, 150 ])
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["w"]).to eq("200")
+ expect(params["h"]).to eq("150")
+ expect(params["fit"]).to eq("contain")
+ end
+ end
+
+ context "with resize_to_fill" do
+ it "transforms resize_to_fill to w, h, and fit=cover" do
+ variant = blob.variant(resize_to_fill: [ 300, 200 ])
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["w"]).to eq("300")
+ expect(params["h"]).to eq("200")
+ expect(params["fit"]).to eq("cover")
+ end
+ end
+
+ context "with quality" do
+ it "transforms quality to q parameter" do
+ variant = blob.variant(quality: 85)
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["q"]).to eq("85")
+ end
+
+ it "rejects invalid quality values" do
+ variant = blob.variant(quality: 150)
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["q"]).to be_nil
+ end
+ end
+
+ context "with format" do
+ it "transforms format to f parameter" do
+ variant = blob.variant(format: :webp)
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["f"]).to eq("webp")
+ end
+
+ it "normalizes jpeg to jpg" do
+ variant = blob.variant(format: :jpeg)
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["f"]).to eq("jpg")
+ end
+ end
+
+ context "with rotation" do
+ it "transforms rotate parameter" do
+ variant = blob.variant(rotate: "-90")
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["r"]).to eq("-90")
+ end
+ end
+
+ context "with multiple transformations" do
+ it "transforms multiple parameters correctly" do
+ variant = blob.variant(
+ resize_to_limit: [ 400, 300 ],
+ quality: 90,
+ format: :webp
+ )
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["w"]).to eq("400")
+ expect(params["h"]).to eq("300")
+ expect(params["fit"]).to eq("scale-down")
+ expect(params["q"]).to eq("90")
+ expect(params["f"]).to eq("webp")
+ end
+ end
+
+ context "with named variants" do
+ it "transforms named variant (:thumb) correctly" do
+ user.avatar.attach(blob)
+ thumb_variant = user.avatar.variant(:thumb)
+ params = described_class.transform_variant_params(thumb_variant)
+
+ # Based on user.rb: attachable.variant :thumb, resize_to_limit: [100, 100]
+ expect(params["w"]).to eq("100")
+ expect(params["h"]).to eq("100")
+ expect(params["fit"]).to eq("scale-down")
+ end
+ end
+
+ context "with non-variant objects" do
+ it "returns empty hash for blobs" do
+ params = described_class.transform_variant_params(blob)
+ expect(params).to eq({})
+ end
+
+ it "returns empty hash for strings" do
+ params = described_class.transform_variant_params("not_a_variant")
+ expect(params).to eq({})
+ end
+ end
+ end
+
+ context "with edge cases and validation" do
+ it "handles invalid quality values by excluding them" do
+ variant = blob.variant(quality: 0)
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["q"]).to be_nil
+ end
+
+ it "handles negative quality values by excluding them" do
+ variant = blob.variant(quality: -10)
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["q"]).to be_nil
+ end
+
+ it "handles quality values over 100 by excluding them" do
+ variant = blob.variant(quality: 150)
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["q"]).to be_nil
+ end
+
+ it "normalizes JPEG format to jpg" do
+ variant = blob.variant(format: "JPEG")
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["f"]).to eq("jpg")
+ end
+
+ it "handles invalid format values by excluding them" do
+ variant = blob.variant(format: "invalid")
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["f"]).to be_nil
+ end
+
+ it "handles unknown format values by excluding them" do
+ variant = blob.variant(format: "unknown")
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["f"]).to be_nil
+ end
+
+ it "normalizes rotation with degrees suffix" do
+ variant = blob.variant(rotate: "-90deg")
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["r"]).to eq("-90")
+ end
+
+ it "normalizes rotation with function syntax" do
+ variant = blob.variant(rotate: "rotate(180)")
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["r"]).to eq("180")
+ end
+
+ it "handles numeric rotation values" do
+ variant = blob.variant(rotate: -90)
+ params = described_class.transform_variant_params(variant)
+
+ expect(params["r"]).to eq("-90")
+ end
+ end
+end
diff --git a/spec/internal/app/models/user.rb b/spec/internal/app/models/user.rb
index 74ea160..d1e978b 100644
--- a/spec/internal/app/models/user.rb
+++ b/spec/internal/app/models/user.rb
@@ -1,4 +1,6 @@
class User < ApplicationRecord
- has_one_attached :avatar
+ has_one_attached :avatar do |attachable|
+ attachable.variant :thumb, resize_to_limit: [ 100, 100 ]
+ end
has_many_attached :images
end