Skip to content

Commit c3a5b2c

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 1777975 commit c3a5b2c

File tree

2 files changed

+202
-40
lines changed

2 files changed

+202
-40
lines changed

lib/tailwindcss/purger.rb

+96-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 class selectors
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,15 @@ 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!
37+
end
38+
39+
def escape_class_selector(class_name)
40+
class_name.gsub(/\A\d|[^-_a-z0-9]/, '\\\\\0')
2041
end
2142
end
2243

@@ -25,40 +46,81 @@ def initialize(keep_these_class_names)
2546
end
2647

2748
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
49+
conveyor = Conveyor.new(input)
50+
51+
until conveyor.done?
52+
conveyor.discard(COMMENTS_AND_BLANK_LINES) \
53+
or conveyor.keep_if(PROPERTIES) { conveyor.staged_output.last != "" } \
54+
or conveyor.keep_if(END_OF_BLOCK) { not conveyor.staged_output.pop } \
55+
or conveyor.stage_for_output(CLASSLESS_BEGINNING_OF_BLOCK) \
56+
or conveyor.stage_for_output(BEGINNING_OF_BLOCK) { |match| purge_beginning_of_block(match.to_s) } \
57+
or raise "infinite loop"
4858
end
4959

50-
separated_without_empty_lines(output)
60+
conveyor.output
5161
end
5262

5363
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")
64+
def keep_these_selectors_pattern
65+
@keep_these_selectors_pattern ||= begin
66+
escaped_classes = @keep_these_class_names.map { |name| Regexp.escape self.class.escape_class_selector(name) }
67+
/(?:\A|,)[^.,{]*(?:[.](?:#{escaped_classes.join("|")})#{CLASS_BREAK}[^.,{]*)*(?=[,{])/
68+
end
69+
end
70+
71+
def purge_beginning_of_block(string)
72+
purged = string.scan(keep_these_selectors_pattern).join
73+
unless purged.empty?
74+
purged.sub!(/\A,\s*/, "")
75+
purged.rstrip!
76+
purged << " {\n"
77+
end
78+
purged
5979
end
6080

61-
def separated_without_empty_lines(output)
62-
output.reject { |line| line.strip.empty? }.join(NEWLINE)
81+
class Conveyor
82+
attr_reader :output, :staged_output
83+
84+
def initialize(input, output = +"")
85+
@input = input
86+
@output = output
87+
@staged_output = []
88+
end
89+
90+
def consume(pattern)
91+
match = pattern.match(@input)
92+
@input = match.post_match if match
93+
match
94+
end
95+
alias :discard :consume
96+
97+
def stage_for_output(pattern)
98+
if match = consume(pattern)
99+
string = block_given? ? (yield match) : match.to_s
100+
@staged_output << string
101+
string
102+
end
103+
end
104+
105+
def append_to_output(pattern)
106+
if match = consume(pattern)
107+
string = block_given? ? (yield match) : match.to_s
108+
@output << @staged_output.shift until @staged_output.empty?
109+
@output << string
110+
string
111+
end
112+
end
113+
alias :keep :append_to_output
114+
115+
def append_to_output_if(pattern)
116+
append_to_output(pattern) do |match|
117+
(yield match) ? match.to_s : (break "")
118+
end
119+
end
120+
alias :keep_if :append_to_output_if
121+
122+
def done?
123+
@input.empty?
124+
end
63125
end
64126
end

test/purger_test.rb

+106-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,32 @@ class Tailwindcss::PurgerTest < ActiveSupport::TestCase
2424
assert purged =~ /.sm\\:py-0\\.5 \{/
2525
end
2626

27+
test "purge handles class names that begin with a number" do
28+
purged = purged_tailwind(keep_these_class_names: %w[32xl:container])
29+
30+
assert_class_selector "32xl:container", purged
31+
end
32+
33+
test "purge removes selectors that aren't on the same line as their block brace" do
34+
purged = purged_tailwind(keep_these_class_names: %w[aspect-w-9])
35+
36+
assert_class_selector "aspect-w-9", purged
37+
assert_no_class_selector "aspect-w-1", purged
38+
assert purged !~ /,\s*@media/
39+
end
40+
41+
test "purge removes empty blocks" do
42+
purged = purged_tailwind_from_fixtures
43+
44+
assert purged !~ /\{\s*\}/
45+
end
46+
47+
test "purge removes top-level comments" do
48+
purged = purged_tailwind_from_fixtures
49+
50+
assert purged !~ /^#{Regexp.escape "/*"}/
51+
end
52+
2753
test "purge shouldn't remove hover or focus classes" do
2854
purged = purged_tailwind_from_fixtures
2955
assert purged =~ /.hover\\\:text-gray-500\:hover \{/
@@ -39,14 +65,88 @@ class Tailwindcss::PurgerTest < ActiveSupport::TestCase
3965
assert purged =~ /.placeholder-transparent\:\:placeholder \{/
4066
end
4167

68+
test "purge handles compound selectors" do
69+
purged = purged_tailwind(keep_these_class_names: %w[group group-hover:text-gray-500])
70+
71+
assert_class_selector "group", purged
72+
assert_class_selector "group-hover:text-gray-500", purged
73+
assert_no_class_selector "group-hover:text-gray-100", purged
74+
end
75+
76+
test "purge handles complex selector groups" do
77+
css = <<~CSS
78+
element.keep, element .keep, .red-herring.discard, .red-herring .discard {
79+
foo: bar;
80+
}
81+
element.discard, element .discard, .red-herring.discard, .red-herring .discard {
82+
baz: qux;
83+
}
84+
CSS
85+
86+
expected = <<~CSS
87+
element.keep, element .keep {
88+
foo: bar;
89+
}
90+
CSS
91+
92+
assert_equal expected, purged_css(css, keep_these_class_names: %w[keep red-herring])
93+
end
94+
95+
test "purge handles nested blocks" do
96+
css = <<~CSS
97+
.keep {
98+
foo: bar;
99+
.discard {
100+
baz: qux;
101+
}
102+
.keep-nested {
103+
bar: foo;
104+
}
105+
}
106+
CSS
107+
108+
expected = <<~CSS
109+
.keep {
110+
foo: bar;
111+
.keep-nested {
112+
bar: foo;
113+
}
114+
}
115+
CSS
116+
117+
assert_equal expected, purged_css(css, keep_these_class_names: %w[keep keep-nested])
118+
end
119+
42120
private
121+
def class_selector_pattern(class_name)
122+
/\.#{Regexp.escape Tailwindcss::Purger.escape_class_selector(class_name)}(?![-_a-z0-9\\])/
123+
end
124+
125+
def assert_class_selector(class_name, css)
126+
assert css =~ class_selector_pattern(class_name)
127+
end
128+
129+
def assert_no_class_selector(class_name, css)
130+
assert css !~ class_selector_pattern(class_name)
131+
end
132+
133+
def purged_css(css, keep_these_class_names:)
134+
Tailwindcss::Purger.new(keep_these_class_names).purge(css)
135+
end
136+
137+
def tailwind
138+
$tailwind ||= Pathname.new(__FILE__).join("../../app/assets/stylesheets/tailwind.css").read.freeze
139+
end
140+
141+
def purged_tailwind(keep_these_class_names:)
142+
purged_css(tailwind, keep_these_class_names: keep_these_class_names)
143+
end
144+
43145
def purged_tailwind_from_fixtures
44-
purged_tailwind_from Pathname(__dir__).glob("fixtures/*.html.erb")
146+
$purged_tailwind_from_fixtures ||= purged_tailwind_from Pathname(__dir__).glob("fixtures/*.html.erb")
45147
end
46148

47149
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
150+
Tailwindcss::Purger.purge tailwind, keeping_class_names_from_files: files
51151
end
52152
end

0 commit comments

Comments
 (0)