diff --git a/lib/tailwindcss/purger.rb b/lib/tailwindcss/purger.rb index 38509d2f..71afaa3a 100644 --- a/lib/tailwindcss/purger.rb +++ b/lib/tailwindcss/purger.rb @@ -1,8 +1,25 @@ +# frozen_string_literal: true + class Tailwindcss::Purger - CLASS_NAME_PATTERN = /([:A-Za-z0-9_-]+[\.\\\/:A-Za-z0-9_-]*)/ - OPENING_SELECTOR_PATTERN = /\..*\{/ - CLOSING_SELECTOR_PATTERN = /\s*\}/ - NEWLINE = "\n" + CLASS_NAME_PATTERN = /[:A-Za-z0-9_-]+[\.]*[\\\/:A-Za-z0-9_-]*/ + + CLASS_BREAK = /(?![-_a-z0-9\\])/i # `\b` for class selectors + + COMMENT = /#{Regexp.escape "/*"}.*?#{Regexp.escape "*/"}/m + COMMENTS_AND_BLANK_LINES = /\A(?:^#{COMMENT}?[ \t]*(?:\n|\z)|[ \t]*#{COMMENT})+/ + + AT_RULE = /@[^{]+/ + CLASSLESS_SELECTOR_GROUP = /[^.{]+/ + CLASSLESS_BEGINNING_OF_BLOCK = /\A\s*(?:#{AT_RULE}|#{CLASSLESS_SELECTOR_GROUP})\{\n?/ + + SELECTOR_GROUP = /[^{]+/ + BEGINNING_OF_BLOCK = /\A#{SELECTOR_GROUP}\{\n?/ + + PROPERTY_NAME = /[-_a-z0-9]+/i + PROPERTY_VALUE = /(?:[^;]|;\S)+/ + PROPERTIES = /\A(?:\s*#{PROPERTY_NAME}:#{PROPERTY_VALUE};\n?)+/ + + END_OF_BLOCK = /\A\s*\}\n?/ attr_reader :keep_these_class_names @@ -12,11 +29,15 @@ def purge(input, keeping_class_names_from_files:) end def extract_class_names(string) - string.scan(CLASS_NAME_PATTERN).flatten.uniq.sort + string.scan(CLASS_NAME_PATTERN).uniq.sort! end def extract_class_names_from(files) - Array(files).flat_map { |file| extract_class_names(file.read) }.uniq.sort + Array(files).flat_map { |file| extract_class_names(file.read) }.uniq.sort! + end + + def escape_class_selector(class_name) + class_name.gsub(/\A\d|[^-_a-z0-9]/, '\\\\\0') end end @@ -25,40 +46,79 @@ def initialize(keep_these_class_names) end def purge(input) - inside_kept_selector = inside_ignored_selector = false - output = [] - - input.split(NEWLINE).each do |line| - case - when inside_kept_selector - output << line - inside_kept_selector = false if line =~ CLOSING_SELECTOR_PATTERN - when inside_ignored_selector - inside_ignored_selector = false if line =~ CLOSING_SELECTOR_PATTERN - when line =~ OPENING_SELECTOR_PATTERN - if keep_these_class_names.include? class_name_in(line) - output << line - inside_kept_selector = true - else - inside_ignored_selector = true - end - else - output << line - end + conveyor = Conveyor.new(input) + + until conveyor.done? + conveyor.discard(COMMENTS_AND_BLANK_LINES) \ + or conveyor.conditionally_keep(PROPERTIES) { conveyor.staged_output.last != "" } \ + or conveyor.conditionally_keep(END_OF_BLOCK) { not conveyor.staged_output.pop } \ + or conveyor.stage_output(CLASSLESS_BEGINNING_OF_BLOCK) \ + or conveyor.stage_output(BEGINNING_OF_BLOCK) { |match| purge_beginning_of_block(match.to_s) } \ + or raise "infinite loop" end - separated_without_empty_lines(output) + conveyor.output end private - def class_name_in(line) - CLASS_NAME_PATTERN.match(line)[1] - .remove("\\") - .remove(/:(focus|hover)(-within)?/) - .remove("::placeholder").remove("::-moz-placeholder").remove(":-ms-input-placeholder") + def keep_these_selectors_pattern + @keep_these_selectors_pattern ||= begin + escaped_classes = @keep_these_class_names.map { |name| Regexp.escape self.class.escape_class_selector(name) } + /(?:\A|,)[^.,{]*(?:[.](?:#{escaped_classes.join("|")})#{CLASS_BREAK}[^.,{]*)*(?=[,{])/ + end + end + + def purge_beginning_of_block(string) + purged = string.scan(keep_these_selectors_pattern).join + unless purged.empty? + purged.sub!(/\A,\s*/, "") + purged.rstrip! + purged << " {\n" + end + purged end - def separated_without_empty_lines(output) - output.reject { |line| line.strip.empty? }.join(NEWLINE) + class Conveyor + attr_reader :output, :staged_output + + def initialize(input, output = +"") + @input = input + @output = output + @staged_output = [] + end + + def consume(pattern) + match = pattern.match(@input) + @input = match.post_match if match + match + end + alias :discard :consume + + def stage_output(pattern) + if match = consume(pattern) + string = block_given? ? (yield match) : match.to_s + @staged_output << string + string + end + end + + def keep(pattern) + if match = consume(pattern) + string = block_given? ? (yield match) : match.to_s + @output << @staged_output.shift until @staged_output.empty? + @output << string + string + end + end + + def conditionally_keep(pattern) + keep(pattern) do |match| + (yield match) ? match.to_s : (break "") + end + end + + def done? + @input.empty? + end end end diff --git a/test/purger_test.rb b/test/purger_test.rb index 35f40fe2..e6f583f5 100644 --- a/test/purger_test.rb +++ b/test/purger_test.rb @@ -13,9 +13,9 @@ class Tailwindcss::PurgerTest < ActiveSupport::TestCase test "basic purge" do purged = purged_tailwind_from_fixtures - + assert purged !~ /.mt-6 \{/ - + assert purged =~ /.mt-5 \{/ assert purged =~ /.sm\\:px-6 \{/ assert purged =~ /.translate-x-1\\\/2 \{/ @@ -24,6 +24,32 @@ class Tailwindcss::PurgerTest < ActiveSupport::TestCase assert purged =~ /.sm\\:py-0\\.5 \{/ end + test "purge handles class names that begin with a number" do + purged = purged_tailwind(keep_these_class_names: %w[32xl:container]) + + assert_class_selector "32xl:container", purged + end + + test "purge removes selectors that aren't on the same line as their block brace" do + purged = purged_tailwind(keep_these_class_names: %w[aspect-w-9]) + + assert_class_selector "aspect-w-9", purged + assert_no_class_selector "aspect-w-1", purged + assert purged !~ /,\s*@media/ + end + + test "purge removes empty blocks" do + purged = purged_tailwind_from_fixtures + + assert purged !~ /\{\s*\}/ + end + + test "purge removes top-level comments" do + purged = purged_tailwind_from_fixtures + + assert purged !~ /^#{Regexp.escape "/*"}/ + end + test "purge shouldn't remove hover or focus classes" do purged = purged_tailwind_from_fixtures assert purged =~ /.hover\\\:text-gray-500\:hover \{/ @@ -39,14 +65,88 @@ class Tailwindcss::PurgerTest < ActiveSupport::TestCase assert purged =~ /.placeholder-transparent\:\:placeholder \{/ end + test "purge handles compound selectors" do + purged = purged_tailwind(keep_these_class_names: %w[group group-hover:text-gray-500]) + + assert_class_selector "group", purged + assert_class_selector "group-hover:text-gray-500", purged + assert_no_class_selector "group-hover:text-gray-100", purged + end + + test "purge handles complex selector groups" do + css = <<~CSS + element.keep, element .keep, .red-herring.discard, .red-herring .discard { + foo: bar; + } + element.discard, element .discard, .red-herring.discard, .red-herring .discard { + baz: qux; + } + CSS + + expected = <<~CSS + element.keep, element .keep { + foo: bar; + } + CSS + + assert_equal expected, purged_css(css, keep_these_class_names: %w[keep red-herring]) + end + + test "purge handles nested blocks" do + css = <<~CSS + .keep { + foo: bar; + .discard { + baz: qux; + } + .keep-nested { + bar: foo; + } + } + CSS + + expected = <<~CSS + .keep { + foo: bar; + .keep-nested { + bar: foo; + } + } + CSS + + assert_equal expected, purged_css(css, keep_these_class_names: %w[keep keep-nested]) + end + private + def class_selector_pattern(class_name) + /\.#{Regexp.escape Tailwindcss::Purger.escape_class_selector(class_name)}(?![-_a-z0-9\\])/ + end + + def assert_class_selector(class_name, css) + assert css =~ class_selector_pattern(class_name) + end + + def assert_no_class_selector(class_name, css) + assert css !~ class_selector_pattern(class_name) + end + + def purged_css(css, keep_these_class_names:) + Tailwindcss::Purger.new(keep_these_class_names).purge(css) + end + + def tailwind + $tailwind ||= Pathname.new(__FILE__).join("../../app/assets/stylesheets/tailwind.css").read.freeze + end + + def purged_tailwind(keep_these_class_names:) + purged_css(tailwind, keep_these_class_names: keep_these_class_names) + end + def purged_tailwind_from_fixtures - purged_tailwind_from Pathname(__dir__).glob("fixtures/*.html.erb") + $purged_tailwind_from_fixtures ||= purged_tailwind_from Pathname(__dir__).glob("fixtures/*.html.erb") end def purged_tailwind_from files - Tailwindcss::Purger.purge \ - Pathname.new(__FILE__).join("../../app/assets/stylesheets/tailwind.css").read, - keeping_class_names_from_files: files + Tailwindcss::Purger.purge tailwind, keeping_class_names_from_files: files end end