Skip to content

Implement Engines support #484

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
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
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
tailwindcss-rails (4.2.2)
tailwindcss-rails (4.2.3)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)

Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,20 @@ Then you can use yarn or npm to install the dependencies.

If you need to use a custom input or output file, you can run `bundle exec tailwindcss` to access the platform-specific executable, and give it your own build options.

## Rails Engines support

If you have Rails Engines in your application that use Tailwind CSS, they will be automatically included in the Tailwind build as long as they conform to next conventions:

- The engine must have `tailwindcss-rails` as gem dependency.
- The engine must have a `app/assets/tailwind/<engine_name>/application.css` file or your application must have overridden file in the same location of your application root.
- The engine must register itself in Tailwindcss Rails:
```ruby
initializer 'your_engine.tailwindcss' do |app|
ActiveSupport.on_load(:tailwindcss_rails) do
config.tailwindcss_rails.engines << Your::Engine.engine_name
end
end
```

## Troubleshooting

Expand Down
43 changes: 38 additions & 5 deletions lib/tailwindcss/commands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
module Tailwindcss
module Commands
class << self
def compile_command(debug: false, **kwargs)
def rails_root
defined?(Rails) ? Rails.root : Pathname.new(Dir.pwd)
end

def compile_command(input = rails_root.join("app/assets/tailwind/application.css").to_s, debug: false, **kwargs)
debug = ENV["TAILWINDCSS_DEBUG"].present? if ENV.key?("TAILWINDCSS_DEBUG")
rails_root = defined?(Rails) ? Rails.root : Pathname.new(Dir.pwd)

command = [
Tailwindcss::Ruby.executable(**kwargs),
"-i", rails_root.join("app/assets/tailwind/application.css").to_s,
"-i", input,
"-o", rails_root.join("app/assets/builds/tailwind.css").to_s,
]

Expand All @@ -21,8 +24,8 @@ def compile_command(debug: false, **kwargs)
command
end

def watch_command(always: false, poll: false, **kwargs)
compile_command(**kwargs).tap do |command|
def watch_command(input = rails_root.join("app/assets/tailwind/application.css").to_s, always: false, poll: false, **kwargs)
compile_command(input, **kwargs).tap do |command|
command << "-w"
command << "always" if always
command << "-p" if poll
Expand All @@ -38,6 +41,36 @@ def command_env(verbose:)
def rails_css_compressor?
defined?(Rails) && Rails&.application&.config&.assets&.css_compressor.present?
end

def engines_roots
return [] unless defined?(Rails)
return [] unless Rails.application&.config&.tailwindcss_rails&.engines

Rails::Engine.descendants.select do |engine|
begin
engine.engine_name.in?(Rails.application.config.tailwindcss_rails.engines)
end
end.map do |engine|
[
Rails.root.join("app/assets/tailwind/#{engine.engine_name}/application.css"),
engine.root.join("app/assets/tailwind/#{engine.engine_name}/application.css")
].select(&:exist?).compact.first.to_s
end.compact
end

def with_dynamic_input
engine_roots = Tailwindcss::Commands.engines_roots
if engine_roots.any?
Tempfile.create('tailwind.css') do |file|
file.write(engine_roots.map { |root| "@import \"#{root}\";" }.join("\n"))
file.write("\n@import \"#{Rails.root.join('app/assets/tailwind/application.css')}\";\n")
file.rewind
yield file.path if block_given?
end
else
yield rails_root.join("app/assets/tailwind/application.css").to_s if block_given?
end
end
end
end
end
7 changes: 7 additions & 0 deletions lib/tailwindcss/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

module Tailwindcss
class Engine < ::Rails::Engine
config.tailwindcss_rails = ActiveSupport::OrderedOptions.new
config.tailwindcss_rails.engines = []

initializer 'tailwindcss.load_hook' do |app|
ActiveSupport.run_load_hooks(:tailwindcss_rails, app)
end

initializer "tailwindcss.disable_generator_stylesheets" do
Rails.application.config.generators.stylesheets = false
end
Expand Down
20 changes: 12 additions & 8 deletions lib/tasks/build.rake
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ namespace :tailwindcss do
debug = args.extras.include?("debug")
verbose = args.extras.include?("verbose")

command = Tailwindcss::Commands.compile_command(debug: debug)
env = Tailwindcss::Commands.command_env(verbose: verbose)
puts "Running: #{Shellwords.join(command)}" if verbose
Tailwindcss::Commands.with_dynamic_input do |input|
command = Tailwindcss::Commands.compile_command(input, debug: debug)
env = Tailwindcss::Commands.command_env(verbose: verbose)
puts "Running: #{Shellwords.join(command)}" if verbose

system(env, *command, exception: true)
system(env, *command, exception: true)
end
end

desc "Watch and build your Tailwind CSS on file changes"
Expand All @@ -18,11 +20,13 @@ namespace :tailwindcss do
always = args.extras.include?("always")
verbose = args.extras.include?("verbose")

command = Tailwindcss::Commands.watch_command(always: always, debug: debug, poll: poll)
env = Tailwindcss::Commands.command_env(verbose: verbose)
puts "Running: #{Shellwords.join(command)}" if verbose
Tailwindcss::Commands.with_dynamic_input do |input|
command = Tailwindcss::Commands.watch_command(input, always: always, debug: debug, poll: poll)
env = Tailwindcss::Commands.command_env(verbose: verbose)
puts "Running: #{Shellwords.join(command)}" if verbose

system(env, *command)
system(env, *command)
end
rescue Interrupt
puts "Received interrupt, exiting tailwindcss:watch" if args.extras.include?("verbose")
end
Expand Down
186 changes: 182 additions & 4 deletions test/lib/tailwindcss/commands_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@
require "minitest/mock"

class Tailwindcss::CommandsTest < ActiveSupport::TestCase
attr_accessor :executable
attr_accessor :executable, :original_rails, :tmp_dir

def setup
super
setup do
@tmp_dir = Dir.mktmpdir
@original_rails = Object.const_get(:Rails) if Object.const_defined?(:Rails)
@executable = Tailwindcss::Ruby.executable
end

teardown do
FileUtils.rm_rf(@tmp_dir)
Tailwindcss::Commands.remove_tempfile! if Tailwindcss::Commands.class_variable_defined?(:@@tempfile)
restore_rails_constant
end

test ".compile_command" do
Rails.stub(:root, File) do # Rails.root won't work in this test suite
actual = Tailwindcss::Commands.compile_command
actual = Tailwindcss::Commands.compile_command("app/assets/tailwind/application.css")
assert_kind_of(Array, actual)
assert_equal(executable, actual.first)
assert_includes(actual, "-i")
Expand Down Expand Up @@ -126,4 +133,175 @@ def setup
assert_includes(actual, "always")
end
end

test ".engines_roots when Rails is not defined" do
Object.send(:remove_const, :Rails) if Object.const_defined?(:Rails)
assert_empty Tailwindcss::Commands.engines_roots
end

test ".engines_roots when no engines are configured" do
with_rails_app do
assert_empty Tailwindcss::Commands.engines_roots
end
end

test ".engines_roots when there are engines" do
within_engine_configs do |engine1, engine2, engine3|
roots = Tailwindcss::Commands.engines_roots

assert_equal 2, roots.size
assert_includes roots, engine1.css_path.to_s
assert_includes roots, engine2.css_path.to_s
refute_includes roots, engine3.css_path.to_s
end
end

test ".with_dynamic_input yields tempfile path when engines exist" do
within_engine_configs do |engine1, engine2|
Tailwindcss::Commands.with_dynamic_input do |css_path|
assert_match(/tailwind\.css/, css_path)
assert File.exist?(css_path)

content = File.read(css_path)
assert_match %r{@import "#{engine1.css_path}";}, content
assert_match %r{@import "#{engine2.css_path}";}, content
assert_match %r{@import "#{Rails.root.join('app/assets/tailwind/application.css')}";}, content
end
end
end

test ".with_dynamic_input yields application.css path when no engines" do
with_rails_app do
expected_path = Rails.root.join("app/assets/tailwind/application.css").to_s
Tailwindcss::Commands.with_dynamic_input do |css_path|
assert_equal expected_path, css_path
end
end
end

test "engines can be configured via tailwindcss_rails.engines" do
with_rails_app do
# Create a test engine
test_engine = Class.new(Rails::Engine) do
def self.engine_name
"test_engine"
end

def self.root
Pathname.new(Dir.mktmpdir)
end
end

# Create CSS file for the engine
engine_css_path = test_engine.root.join("app/assets/tailwind/test_engine/application.css")
FileUtils.mkdir_p(File.dirname(engine_css_path))
FileUtils.touch(engine_css_path)

# Create application-level CSS file
app_css_path = Rails.root.join("app/assets/tailwind/test_engine/application.css")
FileUtils.mkdir_p(File.dirname(app_css_path))
FileUtils.touch(app_css_path)

# Register the engine
Rails::Engine.descendants << test_engine

# Store the hook for later execution
hook = nil
ActiveSupport.on_load(:tailwindcss_rails) do
hook = self
Rails.application.config.tailwindcss_rails.engines << "test_engine"
end

# Trigger the hook manually
ActiveSupport.run_load_hooks(:tailwindcss_rails, hook)

# Verify the engine is included in roots
roots = Tailwindcss::Commands.engines_roots
assert_equal 1, roots.size
assert_includes roots, app_css_path.to_s
ensure
FileUtils.rm_rf(test_engine.root) if defined?(test_engine)
FileUtils.rm_rf(File.dirname(app_css_path)) if defined?(app_css_path)
end
end

private
# Define Structs outside of methods to avoid redefining them
ConfigStruct = Struct.new(:engines)
AssetsStruct = Struct.new(:css_compressor)
TailwindStruct = Struct.new(:tailwindcss_rails, :assets)
AppStruct = Struct.new(:config)
EngineStruct = Struct.new(:name, :root, :css_path)

def with_rails_app
Object.send(:remove_const, :Rails) if Object.const_defined?(:Rails)
Object.const_set(:Rails, setup_mock_rails)
yield
end

def setup_mock_rails
mock_engine = Class.new do
class << self
attr_accessor :engine_name, :root

def descendants
@descendants ||= []
end
end
end

mock_rails = Class.new do
class << self
attr_accessor :root, :application

def const_get(const_name)
return Engine if const_name == :Engine
super
end
end
end

mock_rails.const_set(:Engine, mock_engine)
mock_rails.root = Pathname.new(@tmp_dir)
tailwind_config = ConfigStruct.new([])
assets_config = AssetsStruct.new(nil)
app_config = TailwindStruct.new(tailwind_config, assets_config)
mock_rails.application = AppStruct.new(app_config)
mock_rails
end

def restore_rails_constant
Object.send(:remove_const, :Rails) if Object.const_defined?(:Rails)
Object.const_set(:Rails, @original_rails) if @original_rails
end

def within_engine_configs
engine_configs = create_test_engines
with_rails_app do
Rails.application.config.tailwindcss_rails.engines = %w[test_engine1 test_engine2]

# Create and register mock engine classes
engine_configs.each do |config|
engine_class = Class.new(Rails::Engine)
engine_class.engine_name = config.name
engine_class.root = Pathname.new(config.root)
Rails::Engine.descendants << engine_class
end

yield(*engine_configs)
end
end

def create_test_engines
[1, 2, 3].map do |i|
engine = EngineStruct.new(
"test_engine#{i}",
File.join(@tmp_dir, "engine#{i}"),
File.join(@tmp_dir, "app/assets/tailwind/test_engine#{i}/application.css")
)
FileUtils.mkdir_p(File.dirname(engine.css_path))
FileUtils.touch(engine.css_path)
engine
end
end
end
Loading