Skip to content

Commit 668e679

Browse files
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 ```
1 parent 641c982 commit 668e679

File tree

2 files changed

+177
-40
lines changed

2 files changed

+177
-40
lines changed

lib/tailwindcss/purger.rb

+92-34
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
1+
# frozen_string_literal: true
2+
13
class Tailwindcss::Purger
2-
CLASS_NAME_PATTERN = /([:A-Za-z0-9_-]+[\.\\\/:A-Za-z0-9_-]*)/
3-
OPENING_SELECTOR_PATTERN = /\..*\{/
4-
CLOSING_SELECTOR_PATTERN = /\s*\}/
5-
NEWLINE = "\n"
4+
CLASS_NAME_PATTERN = /[:A-Za-z0-9_-]+[\.]*[\\\/:A-Za-z0-9_-]*/
5+
6+
CLASS_BREAK = /(?![-_a-z0-9\\])/i # `\b` for CSS classes
7+
8+
COMMENT = /#{Regexp.escape "/*"}.*?#{Regexp.escape "*/"}/m
9+
COMMENTS_AND_BLANK_LINES = /\A(?:^#{COMMENT}?[ \t]*(?:\n|\z)|[ \t]*#{COMMENT})+/
10+
11+
AT_RULE = /@[^{]+/
12+
CLASSLESS_SELECTOR_GROUP = /[^.{]+/
13+
CLASSLESS_BEGINNING_OF_BLOCK = /\A\s*(?:#{AT_RULE}|#{CLASSLESS_SELECTOR_GROUP})\{\n?/
14+
15+
SELECTOR_GROUP = /[^{]+/
16+
BEGINNING_OF_BLOCK = /\A#{SELECTOR_GROUP}\{\n?/
17+
18+
PROPERTY_NAME = /[-_a-z0-9]+/i
19+
PROPERTY_VALUE = /(?:[^;]|;\S)+/
20+
PROPERTIES = /\A(?:\s*#{PROPERTY_NAME}:#{PROPERTY_VALUE};\n?)+/
21+
22+
END_OF_BLOCK = /\A\s*\}\n?/
623

724
attr_reader :keep_these_class_names
825

@@ -12,11 +29,11 @@ def purge(input, keeping_class_names_from_files:)
1229
end
1330

1431
def extract_class_names(string)
15-
string.scan(CLASS_NAME_PATTERN).flatten.uniq.sort
32+
string.scan(CLASS_NAME_PATTERN).uniq.sort!
1633
end
1734

1835
def extract_class_names_from(files)
19-
Array(files).flat_map { |file| extract_class_names(file.read) }.uniq.sort
36+
Array(files).flat_map { |file| extract_class_names(file.read) }.uniq.sort!
2037
end
2138
end
2239

@@ -25,40 +42,81 @@ def initialize(keep_these_class_names)
2542
end
2643

2744
def purge(input)
28-
inside_kept_selector = inside_ignored_selector = false
29-
output = []
30-
31-
input.split(NEWLINE).each do |line|
32-
case
33-
when inside_kept_selector
34-
output << line
35-
inside_kept_selector = false if line =~ CLOSING_SELECTOR_PATTERN
36-
when inside_ignored_selector
37-
inside_ignored_selector = false if line =~ CLOSING_SELECTOR_PATTERN
38-
when line =~ OPENING_SELECTOR_PATTERN
39-
if keep_these_class_names.include? class_name_in(line)
40-
output << line
41-
inside_kept_selector = true
42-
else
43-
inside_ignored_selector = true
44-
end
45-
else
46-
output << line
47-
end
45+
conveyor = Conveyor.new(input)
46+
47+
until conveyor.done?
48+
conveyor.discard(COMMENTS_AND_BLANK_LINES) \
49+
or conveyor.keep_if(PROPERTIES) { conveyor.staged_output.last != "" } \
50+
or conveyor.keep_if(END_OF_BLOCK) { not conveyor.staged_output.pop } \
51+
or conveyor.stage_for_output(CLASSLESS_BEGINNING_OF_BLOCK) \
52+
or conveyor.stage_for_output(BEGINNING_OF_BLOCK) { |match| purge_beginning_of_block(match.to_s) } \
53+
or raise "infinite loop"
4854
end
4955

50-
separated_without_empty_lines(output)
56+
conveyor.output
5157
end
5258

5359
private
54-
def class_name_in(line)
55-
CLASS_NAME_PATTERN.match(line)[1]
56-
.remove("\\")
57-
.remove(/:(focus|hover)(-within)?/)
58-
.remove("::placeholder").remove("::-moz-placeholder").remove(":-ms-input-placeholder")
60+
def keep_these_selectors_pattern
61+
@keep_these_selectors_pattern ||= begin
62+
classes = @keep_these_class_names.join("|").gsub(%r"[:./]") { |c| Regexp.escape "\\#{c}" }
63+
/(?:\A|,)[^.,{]*(?:[.](?:#{classes})#{CLASS_BREAK}[^.,{]*)*(?=[,{])/
64+
end
5965
end
6066

61-
def separated_without_empty_lines(output)
62-
output.reject { |line| line.strip.empty? }.join(NEWLINE)
67+
def purge_beginning_of_block(string)
68+
purged = string.scan(keep_these_selectors_pattern).join
69+
unless purged.empty?
70+
purged.sub!(/\A,\s*/, "")
71+
purged.rstrip!
72+
purged << " {\n"
73+
end
74+
purged
75+
end
76+
77+
class Conveyor
78+
attr_reader :output, :staged_output
79+
80+
def initialize(input, output = +"")
81+
@input = input
82+
@output = output
83+
@staged_output = []
84+
end
85+
86+
def consume(pattern)
87+
match = pattern.match(@input)
88+
@input = match.post_match if match
89+
match
90+
end
91+
alias :discard :consume
92+
93+
def stage_for_output(pattern)
94+
if match = consume(pattern)
95+
string = block_given? ? (yield match) : match.to_s
96+
@staged_output << string
97+
string
98+
end
99+
end
100+
101+
def append_to_output(pattern)
102+
if match = consume(pattern)
103+
string = block_given? ? (yield match) : match.to_s
104+
@output << @staged_output.shift until @staged_output.empty?
105+
@output << string
106+
string
107+
end
108+
end
109+
alias :keep :append_to_output
110+
111+
def append_to_output_if(pattern)
112+
append_to_output(pattern) do |match|
113+
(yield match) ? match.to_s : (break "")
114+
end
115+
end
116+
alias :keep_if :append_to_output_if
117+
118+
def done?
119+
@input.empty?
120+
end
63121
end
64122
end

test/purger_test.rb

+85-6
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ class Tailwindcss::PurgerTest < ActiveSupport::TestCase
1313

1414
test "basic purge" do
1515
purged = purged_tailwind_from_fixtures
16-
16+
1717
assert purged !~ /.mt-6 \{/
18-
18+
1919
assert purged =~ /.mt-5 \{/
2020
assert purged =~ /.sm\\:px-6 \{/
2121
assert purged =~ /.translate-x-1\\\/2 \{/
@@ -24,6 +24,27 @@ class Tailwindcss::PurgerTest < ActiveSupport::TestCase
2424
assert purged =~ /.sm\\:py-0\\.5 \{/
2525
end
2626

27+
test "purge removes selectors that aren't on the same line as their block brace" do
28+
purged = purged_tailwind(%w[aspect-w-9])
29+
30+
assert purged !~ /.aspect-w-1,/
31+
assert purged !~ /,\s*@media/
32+
33+
assert purged =~ /.aspect-w-9 \{/
34+
end
35+
36+
test "purge removes empty blocks" do
37+
purged = purged_tailwind_from_fixtures
38+
39+
assert purged !~ /\{\s*\}/
40+
end
41+
42+
test "purge removes top-level comments" do
43+
purged = purged_tailwind_from_fixtures
44+
45+
assert purged !~ /^#{Regexp.escape "/*"}/
46+
end
47+
2748
test "purge shouldn't remove hover or focus classes" do
2849
purged = purged_tailwind_from_fixtures
2950
assert purged =~ /.hover\\\:text-gray-500\:hover \{/
@@ -39,14 +60,72 @@ class Tailwindcss::PurgerTest < ActiveSupport::TestCase
3960
assert purged =~ /.placeholder-transparent\:\:placeholder \{/
4061
end
4162

63+
test "purge handles compound selectors" do
64+
purged = purged_tailwind(%w[group group-hover:text-gray-500])
65+
66+
assert purged !~ /.group-hover\\:text-gray-100/
67+
68+
assert purged =~ /.group:hover .group-hover\\:text-gray-500 \{/
69+
end
70+
71+
test "purge handles complex selector groups" do
72+
input = <<~CSS
73+
element.keep, element .keep, .red-herring.discard, .red-herring .discard {
74+
foo: bar;
75+
}
76+
element.discard, element .discard, .red-herring.discard, .red-herring .discard {
77+
baz: qux;
78+
}
79+
CSS
80+
81+
expected = <<~CSS
82+
element.keep, element .keep {
83+
foo: bar;
84+
}
85+
CSS
86+
87+
assert_equal expected, Tailwindcss::Purger.new(%w[keep red-herring]).purge(input)
88+
end
89+
90+
test "purge handles nested blocks" do
91+
input = <<~CSS
92+
.keep {
93+
foo: bar;
94+
.discard {
95+
baz: qux;
96+
}
97+
.keep-nested {
98+
bar: foo;
99+
}
100+
}
101+
CSS
102+
103+
expected = <<~CSS
104+
.keep {
105+
foo: bar;
106+
.keep-nested {
107+
bar: foo;
108+
}
109+
}
110+
CSS
111+
112+
assert_equal expected, Tailwindcss::Purger.new(%w[keep keep-nested]).purge(input)
113+
end
114+
42115
private
116+
def tailwind
117+
$tailwind ||= Pathname.new(__FILE__).join("../../app/assets/stylesheets/tailwind.css").read.freeze
118+
end
119+
120+
def purged_tailwind(keep_these_class_names)
121+
Tailwindcss::Purger.new(keep_these_class_names).purge(tailwind)
122+
end
123+
43124
def purged_tailwind_from_fixtures
44-
purged_tailwind_from Pathname(__dir__).glob("fixtures/*.html.erb")
125+
$purged_tailwind_from_fixtures ||= purged_tailwind_from Pathname(__dir__).glob("fixtures/*.html.erb")
45126
end
46127

47128
def purged_tailwind_from files
48-
Tailwindcss::Purger.purge \
49-
Pathname.new(__FILE__).join("../../app/assets/stylesheets/tailwind.css").read,
50-
keeping_class_names_from_files: files
129+
Tailwindcss::Purger.purge tailwind, keeping_class_names_from_files: files
51130
end
52131
end

0 commit comments

Comments
 (0)