diff --git a/Gemfile.lock b/Gemfile.lock index f84bada9..1d8405c8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/README.md b/README.md index 158b1750..f9dad69c 100644 --- a/README.md +++ b/README.md @@ -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//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 diff --git a/lib/tailwindcss/commands.rb b/lib/tailwindcss/commands.rb index 99ad30e0..f5be585b 100644 --- a/lib/tailwindcss/commands.rb +++ b/lib/tailwindcss/commands.rb @@ -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, ] @@ -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 @@ -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 diff --git a/lib/tailwindcss/engine.rb b/lib/tailwindcss/engine.rb index 7b88c5f1..6beb65ba 100644 --- a/lib/tailwindcss/engine.rb +++ b/lib/tailwindcss/engine.rb @@ -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 diff --git a/lib/tasks/build.rake b/lib/tasks/build.rake index 603c8059..1fecda29 100644 --- a/lib/tasks/build.rake +++ b/lib/tasks/build.rake @@ -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" @@ -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 diff --git a/test/lib/tailwindcss/commands_test.rb b/test/lib/tailwindcss/commands_test.rb index d09481a4..dbeacb89 100644 --- a/test/lib/tailwindcss/commands_test.rb +++ b/test/lib/tailwindcss/commands_test.rb @@ -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") @@ -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