Skip to content

Rewrite Purger #46

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

Merged
merged 1 commit into from
Aug 18, 2021
Merged
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
Rewrite Purger
This rewrite accomplishes a few things:

* Fixes #20.  Closes #22.

* Purges selectors that aren't on the same line as their block brace:

    **Before**

    ```css
    .sm\:aspect-w-1,
    /* ... */
    .sm\:aspect-w-15 > *,
    @media (prefers-color-scheme: dark) {
    }
    ```

* Purges empty "at rule" blocks (e.g. empty `@media` blocks).

* Purges comments, except within property values.

* Adds support for nested selector blocks, to prepare for the future.
  For example, Tailwind has many `.group:hover .group-hover\:X` rules
  that could be written as `.group:hover { .group-hover\:X { ... } }`.

* Improves performance:

    **Before**

    ```bash
    $ bin/test -n test_basic_purge

    Finished in 2.307380s, 0.4334 runs/s, 3.0337 assertions/s.
    1 runs, 7 assertions, 0 failures, 0 errors, 0 skips
    ```

    **After**

    ```bash
    $ bin/test -n test_basic_purge

    Finished in 1.824162s, 0.5482 runs/s, 3.8374 assertions/s.
    1 runs, 7 assertions, 0 failures, 0 errors, 0 skips
    ```
  • Loading branch information
jonathanhefner committed Feb 22, 2021
commit 307ac1bf044b7cdd159d995ab9c0285ad7f5c05c
128 changes: 94 additions & 34 deletions lib/tailwindcss/purger.rb
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand All @@ -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
112 changes: 106 additions & 6 deletions test/purger_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 \{/
Expand All @@ -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 \{/
Expand All @@ -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