diff --git a/CLAUDE.md b/CLAUDE.md index 11db572..c20aac1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,10 @@ bundle exec rubocop # Run RuboCop linter (uses rubocop-rails-omakase) ### Icon Management ```bash -rake move_to_assets # Move icons from tmp/heroicons to app/assets/images/icons/ +rake move_to_assets # Move icons from tmp/heroicons to app/assets/images/icons/ +rake heroicons:sync # Sync icons: copy used icons, remove unused ones +rake heroicons:dry_run # Preview what would be synced (dry run) +rake heroicons:list_used # List all icons currently used in the application ``` ### Gem Development @@ -60,4 +63,48 @@ The gem is designed to be included in Rails applications via: - Icons are organized by type (outline/solid/mini/micro) corresponding to different sizes and styles from Heroicons - The `icon_tag` helper defaults to `:outline` type and `"w-6 h-6"` classes - Icon lookup first checks the Rails app's assets, then falls back to the gem's assets -- The `move_to_assets` rake task syncs icons from the upstream Heroicons repository \ No newline at end of file +- The `move_to_assets` rake task syncs icons from the upstream Heroicons repository + +## Automatic Icon Management + +The gem includes an intelligent icon management system that automatically manages which icons are included in your Rails application's assets: + +### How It Works + +1. **IconScanner**: Scans your Rails application code (views, helpers, components, controllers) for `icon_tag` usage +2. **IconSyncer**: Copies only the used icons from the gem to your app's `app/assets/images/icons/` directory and removes unused ones + +### Usage in Host Applications + +**Manual Sync** (recommended workflow): +```bash +# Preview what would change +rake heroicons:sync + +# List all icons currently used +rake heroicons:list_used + +# Preview sync without making changes +rake heroicons:dry_run +``` + +**Auto-sync on Asset Precompilation** (optional): +```ruby +# config/application.rb +config.heroicons.auto_sync = true # Auto-sync before assets:precompile +``` + +### Benefits + +- **Smaller asset size**: Only includes icons you actually use +- **Clean assets directory**: Automatically removes icons when you stop using them +- **Version control friendly**: Your app only commits the icons it needs +- **Easy auditing**: Use `rake heroicons:list_used` to see all icons in use + +### Technical Details + +- **IconScanner** (`lib/heroicons/icon_scanner.rb`): Searches for `icon_tag` calls using regex patterns +- **IconSyncer** (`lib/heroicons/icon_syncer.rb`): Manages copying/removing icon files +- **Rake Tasks** (`lib/tasks/heroicons.rake`): Provides CLI commands for icon management +- Supports all icon name formats: symbols, strings, underscored, and dashed names +- Automatically normalizes underscored names to dashed format for file lookup \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index ee8d979..fae9d81 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,13 @@ -GIT - remote: https://github.com/rails/rails.git - revision: c72f6713a427851a20a46c00c8ef3ab689d5bd32 - branch: main +PATH + remote: . specs: + heroicons-rails (0.4.1) + +GEM + remote: https://rubygems.org/ + specs: + action_text-trix (2.1.15) + railties actioncable (8.1.0.beta1) actionpack (= 8.1.0.beta1) activesupport (= 8.1.0.beta1) @@ -75,40 +80,6 @@ GIT securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - rails (8.1.0.beta1) - actioncable (= 8.1.0.beta1) - actionmailbox (= 8.1.0.beta1) - actionmailer (= 8.1.0.beta1) - actionpack (= 8.1.0.beta1) - actiontext (= 8.1.0.beta1) - actionview (= 8.1.0.beta1) - activejob (= 8.1.0.beta1) - activemodel (= 8.1.0.beta1) - activerecord (= 8.1.0.beta1) - activestorage (= 8.1.0.beta1) - activesupport (= 8.1.0.beta1) - bundler (>= 1.15.0) - railties (= 8.1.0.beta1) - railties (8.1.0.beta1) - actionpack (= 8.1.0.beta1) - activesupport (= 8.1.0.beta1) - irb (~> 1.13) - rackup (>= 1.0.0) - rake (>= 12.2) - thor (~> 1.0, >= 1.2.2) - tsort (>= 0.2) - zeitwerk (~> 2.6) - -PATH - remote: . - specs: - heroicons-rails (0.4.1) - -GEM - remote: https://rubygems.org/ - specs: - action_text-trix (2.1.15) - railties ast (2.4.3) base64 (0.3.0) benchmark (0.4.1) @@ -193,6 +164,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) + rails (8.1.0.beta1) + actioncable (= 8.1.0.beta1) + actionmailbox (= 8.1.0.beta1) + actionmailer (= 8.1.0.beta1) + actionpack (= 8.1.0.beta1) + actiontext (= 8.1.0.beta1) + actionview (= 8.1.0.beta1) + activejob (= 8.1.0.beta1) + activemodel (= 8.1.0.beta1) + activerecord (= 8.1.0.beta1) + activestorage (= 8.1.0.beta1) + activesupport (= 8.1.0.beta1) + bundler (>= 1.15.0) + railties (= 8.1.0.beta1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -200,6 +185,15 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.1.0.beta1) + actionpack (= 8.1.0.beta1) + activesupport (= 8.1.0.beta1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.0) rbs (3.9.4) @@ -283,7 +277,7 @@ PLATFORMS DEPENDENCIES heroicons-rails! puma - rails! + rails rubocop-rails-omakase ruby-lsp sqlite3 diff --git a/README.md b/README.md index d44034a..990ddec 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,50 @@ app/assets/images/icons/custom/ <%= icon_tag "custom-arrow", type: :custom, class: "w-4 h-4" %> ``` +## Icon Management + +The gem includes intelligent icon management that keeps your assets directory clean and optimized: + +### Automatic Icon Syncing + +Only include icons you actually use in your application's assets: + +```bash +# Scan your app and sync icons (copies used icons, removes unused ones) +$ rake heroicons:sync + +# Preview what would be synced without making changes +$ rake heroicons:dry_run + +# List all icons currently used in your application +$ rake heroicons:list_used +``` + +### How It Works + +1. The scanner searches your Rails app for `icon_tag` usage in views, helpers, and components +2. Only the icons you're actually using are copied to `app/assets/images/icons/` +3. Icons you've removed from your code are automatically deleted from assets +4. Your repository stays clean with only the icons you need + +### Benefits + +- **Smaller asset bundles**: Only includes icons you use +- **Automatic cleanup**: Removes unused icons automatically +- **Easy auditing**: See exactly which icons your app uses +- **Version control friendly**: Only commit icons you actually need + +### Optional Auto-sync + +Enable automatic syncing before asset precompilation: + +```ruby +# config/application.rb +config.heroicons.auto_sync = true +``` + +When enabled, icons will be automatically synced before `rake assets:precompile` runs. + ## Installation Add this line to your application's Gemfile: diff --git a/lib/heroicons-rails.rb b/lib/heroicons-rails.rb index 45f0370..762da32 100644 --- a/lib/heroicons-rails.rb +++ b/lib/heroicons-rails.rb @@ -1,6 +1,8 @@ require_relative "heroicons/version" require_relative "heroicons/engine" require_relative "heroicons/errors" +require_relative "heroicons/icon_scanner" +require_relative "heroicons/icon_syncer" module Heroicons def self.root diff --git a/lib/heroicons/engine.rb b/lib/heroicons/engine.rb index df8b947..bde90a7 100644 --- a/lib/heroicons/engine.rb +++ b/lib/heroicons/engine.rb @@ -2,10 +2,26 @@ module Heroicons class Engine < ::Rails::Engine isolate_namespace Heroicons + # Configuration options + config.heroicons = ActiveSupport::OrderedOptions.new + config.heroicons.auto_sync = false # Set to true to auto-sync icons before asset compilation + + # Load rake tasks + rake_tasks do + load File.expand_path("../tasks/heroicons.rake", __dir__) + end + config.to_prepare do ActiveSupport.on_load(:action_view) do include Heroicons::ApplicationHelper end end + + # Auto-sync icons before asset precompilation if enabled + config.before_initialize do |app| + if app.config.heroicons.auto_sync && defined?(Rake) && Rake::Task.task_defined?("assets:precompile") + Rake::Task["assets:precompile"].enhance(["heroicons:sync"]) + end + end end end diff --git a/lib/heroicons/icon_scanner.rb b/lib/heroicons/icon_scanner.rb new file mode 100644 index 0000000..0d37746 --- /dev/null +++ b/lib/heroicons/icon_scanner.rb @@ -0,0 +1,62 @@ +module Heroicons + # Scans Rails application for icon_tag usage to determine which icons are being used + class IconScanner + # Matches: icon_tag(:name), icon_tag("name"), icon_tag :name, icon_tag "name" + # Also captures type parameter if present: type: :solid + ICON_TAG_PATTERN = /icon_tag\s*\(?\s*[:"']([a-z_-]+)["']?\s*(?:,\s*type:\s*:([a-z]+))?\)?/i + SEARCHABLE_EXTENSIONS = %w[.erb .haml .slim .rb].freeze + + attr_reader :rails_root + + def initialize(rails_root = Rails.root) + @rails_root = rails_root + end + + # Returns a hash of used icons grouped by type + # Example: { outline: ['x-mark', 'home'], solid: ['check'], mini: ['star'] } + def scan_used_icons + used_icons = Hash.new { |h, k| h[k] = Set.new } + + searchable_files.each do |file_path| + content = File.read(file_path) + extract_icons_from_content(content, used_icons) + end + + # Convert sets to sorted arrays + used_icons.transform_values { |set| set.to_a.sort } + rescue => e + Rails.logger&.error("[Heroicons] Error scanning for icons: #{e.message}") + {} + end + + private + + def searchable_files + search_paths = [ + File.join(rails_root, "app/views/**/*"), + File.join(rails_root, "app/helpers/**/*"), + File.join(rails_root, "app/components/**/*"), + File.join(rails_root, "app/controllers/**/*") + ] + + search_paths.flat_map do |pattern| + Dir.glob(pattern).select do |path| + File.file?(path) && SEARCHABLE_EXTENSIONS.any? { |ext| path.end_with?(ext) } + end + end + end + + def extract_icons_from_content(content, used_icons) + content.scan(ICON_TAG_PATTERN) do |match| + icon_name = match[0]&.strip + icon_type = match[1]&.strip&.to_sym || :outline + + next unless icon_name + + # Normalize icon name: convert underscores to dashes + normalized_name = icon_name.tr("_", "-") + used_icons[icon_type] << normalized_name + end + end + end +end diff --git a/lib/heroicons/icon_syncer.rb b/lib/heroicons/icon_syncer.rb new file mode 100644 index 0000000..f748dab --- /dev/null +++ b/lib/heroicons/icon_syncer.rb @@ -0,0 +1,141 @@ +require "fileutils" + +module Heroicons + # Syncs icons from gem to Rails app assets based on usage + # Copies only used icons and removes unused ones + class IconSyncer + ICON_TYPES = %i[outline solid mini micro].freeze + + attr_reader :rails_root, :scanner + + def initialize(rails_root = Rails.root, scanner: nil) + @rails_root = rails_root + @scanner = scanner || IconScanner.new(rails_root) + end + + # Syncs icons: copies used ones from gem, removes unused ones from app + # Returns stats hash with copied, removed, and kept counts + def sync! + used_icons = scanner.scan_used_icons + stats = { copied: 0, removed: 0, kept: 0, errors: [] } + + ICON_TYPES.each do |type| + sync_icons_for_type(type, used_icons[type] || [], stats) + end + + log_sync_results(stats) + stats + end + + # Dry run - shows what would be synced without making changes + def dry_run + used_icons = scanner.scan_used_icons + report = { would_copy: [], would_remove: [], would_keep: [] } + + ICON_TYPES.each do |type| + analyze_sync_for_type(type, used_icons[type] || [], report) + end + + report + end + + private + + def sync_icons_for_type(type, used_icon_names, stats) + app_icons_dir = app_icons_path(type) + gem_icons_dir = gem_icons_path(type) + + # Create directory if it doesn't exist + FileUtils.mkdir_p(app_icons_dir) unless used_icon_names.empty? + + # Get currently existing icons in app + existing_icons = existing_app_icons(type) + + # Copy used icons that don't exist or are outdated + used_icon_names.each do |icon_name| + icon_file = "#{icon_name}.svg" + gem_icon_path = File.join(gem_icons_dir, icon_file) + app_icon_path = File.join(app_icons_dir, icon_file) + + if File.exist?(gem_icon_path) + if !File.exist?(app_icon_path) || file_needs_update?(gem_icon_path, app_icon_path) + FileUtils.cp(gem_icon_path, app_icon_path) + stats[:copied] += 1 + else + stats[:kept] += 1 + end + else + stats[:errors] << "Icon not found in gem: #{type}/#{icon_name}" + end + end + + # Remove icons that are no longer used + unused_icons = existing_icons - used_icon_names.map { |n| "#{n}.svg" } + unused_icons.each do |icon_file| + app_icon_path = File.join(app_icons_dir, icon_file) + FileUtils.rm(app_icon_path) if File.exist?(app_icon_path) + stats[:removed] += 1 + end + + # Remove directory if empty + FileUtils.rmdir(app_icons_dir) if Dir.empty?(app_icons_dir) && Dir.exist?(app_icons_dir) + rescue => e + stats[:errors] << "Error syncing #{type} icons: #{e.message}" + end + + def analyze_sync_for_type(type, used_icon_names, report) + existing_icons = existing_app_icons(type) + gem_icons_dir = gem_icons_path(type) + + used_icon_names.each do |icon_name| + icon_file = "#{icon_name}.svg" + app_icon_path = File.join(app_icons_path(type), icon_file) + + if File.exist?(app_icon_path) + report[:would_keep] << "#{type}/#{icon_file}" + elsif File.exist?(File.join(gem_icons_dir, icon_file)) + report[:would_copy] << "#{type}/#{icon_file}" + end + end + + unused_icons = existing_icons - used_icon_names.map { |n| "#{n}.svg" } + unused_icons.each do |icon_file| + report[:would_remove] << "#{type}/#{icon_file}" + end + end + + def app_icons_path(type) + File.join(rails_root, "app/assets/images/icons/#{type}") + end + + def gem_icons_path(type) + File.join(Heroicons.root, "app/assets/images/icons/#{type}") + end + + def existing_app_icons(type) + icons_dir = app_icons_path(type) + return [] unless Dir.exist?(icons_dir) + + Dir.children(icons_dir).select { |f| f.end_with?(".svg") } + end + + def file_needs_update?(source, destination) + File.mtime(source) > File.mtime(destination) + rescue + true + end + + def log_sync_results(stats) + return unless Rails.logger + + Rails.logger.info("[Heroicons] Icon sync complete:") + Rails.logger.info(" - Copied: #{stats[:copied]}") + Rails.logger.info(" - Removed: #{stats[:removed]}") + Rails.logger.info(" - Kept: #{stats[:kept]}") + + stats[:errors].each do |error| + Rails.logger.warn(" - #{error}") + end + end + end +end diff --git a/lib/tasks/heroicons.rake b/lib/tasks/heroicons.rake new file mode 100644 index 0000000..bd8c81d --- /dev/null +++ b/lib/tasks/heroicons.rake @@ -0,0 +1,75 @@ +namespace :heroicons do + desc "Sync icons: copy used icons from gem to app/assets, remove unused ones" + task sync: :environment do + require "heroicons-rails" + + puts "Scanning application for icon usage..." + syncer = Heroicons::IconSyncer.new + + stats = syncer.sync! + + puts "\n✓ Icon sync complete!" + puts " #{stats[:copied]} icons copied" + puts " #{stats[:removed]} icons removed" + puts " #{stats[:kept]} icons already up to date" + + if stats[:errors].any? + puts "\n⚠ Warnings:" + stats[:errors].each { |err| puts " - #{err}" } + end + end + + desc "Show which icons would be synced (dry run)" + task dry_run: :environment do + require "heroicons-rails" + + puts "Analyzing icon usage..." + syncer = Heroicons::IconSyncer.new + + report = syncer.dry_run + + puts "\n--- Dry Run Report ---" + + if report[:would_copy].any? + puts "\nWould COPY (#{report[:would_copy].size}):" + report[:would_copy].each { |icon| puts " + #{icon}" } + end + + if report[:would_remove].any? + puts "\nWould REMOVE (#{report[:would_remove].size}):" + report[:would_remove].each { |icon| puts " - #{icon}" } + end + + if report[:would_keep].any? + puts "\nWould KEEP (#{report[:would_keep].size}):" + report[:would_keep].each { |icon| puts " = #{icon}" } + end + + puts "\nRun 'rake heroicons:sync' to apply these changes." + end + + desc "List all icons currently used in the application" + task list_used: :environment do + require "heroicons-rails" + + puts "Scanning application for icon usage...\n" + scanner = Heroicons::IconScanner.new + + used_icons = scanner.scan_used_icons + + if used_icons.empty? + puts "No icons found in use." + else + total = used_icons.values.sum(&:size) + puts "Found #{total} unique icons in use:\n\n" + + used_icons.each do |type, icons| + next if icons.empty? + + puts "#{type.to_s.upcase} (#{icons.size}):" + icons.each { |icon| puts " - #{icon}" } + puts "" + end + end + end +end diff --git a/test/heroicons_rails/icon_scanner_test.rb b/test/heroicons_rails/icon_scanner_test.rb new file mode 100644 index 0000000..9495c35 --- /dev/null +++ b/test/heroicons_rails/icon_scanner_test.rb @@ -0,0 +1,73 @@ +require "test_helper" + +class Heroicons::IconScannerTest < ActiveSupport::TestCase + test "scans icon_tag calls with icon names" do + scanner = Heroicons::IconScanner.new(Rails.root) + content = <<~RUBY + <%= icon_tag :home %> + <%= icon_tag "user" %> + <%= icon_tag :x_mark, type: :solid %> + RUBY + + used_icons = Hash.new { |h, k| h[k] = Set.new } + scanner.send(:extract_icons_from_content, content, used_icons) + + assert_includes used_icons[:outline], "home" + assert_includes used_icons[:outline], "user" + assert_includes used_icons[:solid], "x-mark" # Should normalize underscores + end + + test "normalizes underscored icon names to dashes" do + scanner = Heroicons::IconScanner.new(Rails.root) + content = "<%= icon_tag :academic_cap %>" + + used_icons = Hash.new { |h, k| h[k] = Set.new } + scanner.send(:extract_icons_from_content, content, used_icons) + + assert_includes used_icons[:outline], "academic-cap" + assert_not_includes used_icons[:outline], "academic_cap" + end + + test "detects icon types correctly" do + scanner = Heroicons::IconScanner.new(Rails.root) + content = <<~RUBY + <%= icon_tag :home %> + <%= icon_tag :check, type: :solid %> + <%= icon_tag :star, type: :mini %> + <%= icon_tag :bell, type: :micro %> + RUBY + + used_icons = Hash.new { |h, k| h[k] = Set.new } + scanner.send(:extract_icons_from_content, content, used_icons) + + assert_includes used_icons[:outline], "home" + assert_includes used_icons[:solid], "check" + assert_includes used_icons[:mini], "star" + assert_includes used_icons[:micro], "bell" + end + + test "handles various syntax formats" do + scanner = Heroicons::IconScanner.new(Rails.root) + content = <<~RUBY + <%= icon_tag(:home) %> + <%= icon_tag :user, class: "w-4 h-4" %> + <%= icon_tag "settings" %> + <%= icon_tag("menu") %> + RUBY + + used_icons = Hash.new { |h, k| h[k] = Set.new } + scanner.send(:extract_icons_from_content, content, used_icons) + + assert_includes used_icons[:outline], "home" + assert_includes used_icons[:outline], "user" + assert_includes used_icons[:outline], "settings" + assert_includes used_icons[:outline], "menu" + end + + test "returns empty hash on scan error" do + scanner = Heroicons::IconScanner.new("/nonexistent/path") + result = scanner.scan_used_icons + + assert_equal({}, result) + end +end diff --git a/test/heroicons_rails/icon_syncer_test.rb b/test/heroicons_rails/icon_syncer_test.rb new file mode 100644 index 0000000..b84aae6 --- /dev/null +++ b/test/heroicons_rails/icon_syncer_test.rb @@ -0,0 +1,83 @@ +require "test_helper" +require "fileutils" +require "tmpdir" +require "minitest/mock" + +class Heroicons::IconSyncerTest < ActiveSupport::TestCase + def setup + @temp_dir = Dir.mktmpdir + @mock_scanner = Minitest::Mock.new + end + + def teardown + FileUtils.rm_rf(@temp_dir) if @temp_dir && Dir.exist?(@temp_dir) + end + + test "dry_run returns report without making changes" do + @mock_scanner.expect(:scan_used_icons, { outline: ["home", "user"] }) + + syncer = Heroicons::IconSyncer.new(@temp_dir, scanner: @mock_scanner) + report = syncer.dry_run + + assert_respond_to report, :[] + assert_includes report.keys, :would_copy + assert_includes report.keys, :would_remove + assert_includes report.keys, :would_keep + + @mock_scanner.verify + end + + test "sync! returns stats with counts" do + @mock_scanner.expect(:scan_used_icons, {}) + + syncer = Heroicons::IconSyncer.new(@temp_dir, scanner: @mock_scanner) + stats = syncer.sync! + + assert_respond_to stats, :[] + assert_includes stats.keys, :copied + assert_includes stats.keys, :removed + assert_includes stats.keys, :kept + assert_includes stats.keys, :errors + + @mock_scanner.verify + end + + test "gem_icons_path returns correct path" do + syncer = Heroicons::IconSyncer.new(@temp_dir) + path = syncer.send(:gem_icons_path, :outline) + + assert_includes path, "app/assets/images/icons/outline" + assert_includes path, Heroicons.root + end + + test "app_icons_path returns correct path" do + syncer = Heroicons::IconSyncer.new(@temp_dir) + path = syncer.send(:app_icons_path, :solid) + + assert_includes path, "app/assets/images/icons/solid" + assert_includes path, @temp_dir + end + + test "existing_app_icons returns empty array for nonexistent directory" do + syncer = Heroicons::IconSyncer.new(@temp_dir) + icons = syncer.send(:existing_app_icons, :outline) + + assert_equal [], icons + end + + test "existing_app_icons returns svg files from directory" do + icons_dir = File.join(@temp_dir, "app/assets/images/icons/outline") + FileUtils.mkdir_p(icons_dir) + FileUtils.touch(File.join(icons_dir, "home.svg")) + FileUtils.touch(File.join(icons_dir, "user.svg")) + FileUtils.touch(File.join(icons_dir, "readme.txt")) # Should be ignored + + syncer = Heroicons::IconSyncer.new(@temp_dir) + icons = syncer.send(:existing_app_icons, :outline) + + assert_equal 2, icons.size + assert_includes icons, "home.svg" + assert_includes icons, "user.svg" + assert_not_includes icons, "readme.txt" + end +end