Skip to content

Commit 95e0bf4

Browse files
authored
Merge pull request #5 from gjtorikian/support-custom-seperators
Support custom seperators
2 parents 221abdd + 25daf23 commit 95e0bf4

File tree

10 files changed

+173
-93
lines changed

10 files changed

+173
-93
lines changed

.github/dependabot.yml

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
version: 2
22
updates:
3-
- package-ecosystem: "github-actions"
4-
directory: "/"
5-
schedule:
6-
interval: "weekly"
3+
- package-ecosystem: "github-actions"
4+
directory: "/"
5+
schedule:
6+
interval: daily
7+
time: "09:00"
8+
timezone: "Etc/UTC"
9+
open-pull-requests-limit: 10
10+
11+
- package-ecosystem: "bundler"
12+
directory: "/"
13+
schedule:
14+
interval: daily
15+
time: "09:00"
16+
timezone: "Etc/UTC"
17+
open-pull-requests-limit: 10
18+
allow:
19+
- dependency-name: "*"
20+
dependency-type: "production"

.github/workflows/automerge.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: PR auto-{approve,merge}
2+
3+
on:
4+
pull_request_target:
5+
6+
permissions:
7+
pull-requests: write
8+
contents: write
9+
10+
jobs:
11+
dependabot:
12+
name: Dependabot
13+
runs-on: ubuntu-latest
14+
15+
if: ${{ github.actor == 'dependabot[bot]' }}
16+
steps:
17+
- name: Fetch Dependabot metadata
18+
id: dependabot-metadata
19+
uses: dependabot/fetch-metadata@v1
20+
with:
21+
github-token: "${{ secrets.GITHUB_TOKEN }}"
22+
23+
- name: Approve Dependabot PR
24+
if: ${{steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major'}}
25+
run: gh pr review --approve "$PR_URL"
26+
env:
27+
PR_URL: ${{github.event.pull_request.html_url}}
28+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
29+
30+
- name: Merge Dependabot PR
31+
run: gh pr merge --auto --squash "$PR_URL"
32+
env:
33+
PR_URL: ${{ github.event.pull_request.html_url }}
34+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.github/workflows/lint.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ name: Linting
22

33
on:
44
pull_request:
5+
paths:
6+
- "**/*.rb"
57

68
permissions:
79
contents: read

README.md

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,7 @@ If you use Tailwind with a component-based UI renderer (like [ViewComponent](htt
2727

2828
```html
2929
<!-- app/components/confirm_email_component.html.erb -->
30-
<div class="border rounded px-2 py-1">
31-
Please confirm your email address.
32-
</div>
30+
<div class="border rounded px-2 py-1">Please confirm your email address.</div>
3331
```
3432

3533
```ruby
@@ -53,9 +51,9 @@ tailwind-merge overrides conflicting classes and keeps everything else untouched
5351
5452
### Optimized for speed
5553
56-
- Results get cached by default, so you don't need to worry about wasteful re-renders. The library uses a thread-safe [LRU cache](<https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU)>) which stores up to 500 different results. The cache size can be modified via config options.
57-
- Expensive computations happen upfront so that `merge` calls without a cache hit stay fast.
58-
- These computations are called lazily on the first call to `merge` to prevent it from impacting app startup performance if it isn't used initially.
54+
- Results get cached by default, so you don't need to worry about wasteful re-renders. The library uses a thread-safe [LRU cache](<https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU)>) which stores up to 500 different results. The cache size can be modified via config options.
55+
- Expensive computations happen upfront so that `merge` calls without a cache hit stay fast.
56+
- These computations are called lazily on the first call to `merge` to prevent it from impacting app startup performance if it isn't used initially.
5957
6058
### Last conflicting class wins
6159
@@ -150,10 +148,10 @@ If you're using a custom Tailwind config, you may need to configure tailwind-mer
150148

151149
The default [`twMerge`](#twmerge) function is configured in a way that you can still use it if all the following points apply to your Tailwind config:
152150

153-
- Only using color names which don't clash with other Tailwind class names
154-
- Only deviating by number values from number-based Tailwind classes
155-
- Only using font-family classes which don't clash with default font-weight classes
156-
- Sticking to default Tailwind config for everything else
151+
- Only using color names which don't clash with other Tailwind class names
152+
- Only deviating by number values from number-based Tailwind classes
153+
- Only using font-family classes which don't clash with default font-weight classes
154+
- Sticking to default Tailwind config for everything else
157155

158156
If some of these points don't apply to you, you can test whether the merge still works as intended with your custom classes. Otherwise, you need create your own custom merge function by either extending the default tailwind-merge config or using a completely custom one.
159157

@@ -165,9 +163,11 @@ The `tailwind_merge` config is an object with several keys:
165163

166164
```ruby
167165
tailwindMergeConfig = {
168-
#Set how many values should be stored in cache.
166+
#*Optional* Define how many values should be stored in cache.
169167
cache_size: 500,
170-
# ↓ Optional prefix from Tailwind config
168+
# ↓ *Optional* modifier separator from Tailwind config
169+
separator: ':',
170+
# ↓ *Optional* prefix from Tailwind config
171171
prefix: 'tw-',
172172
theme: {
173173
# Theme scales are defined here
@@ -283,17 +283,17 @@ If you modified one of these theme scales in your Tailwind config, you can add a
283283

284284
Here's a brief summary for each validator:
285285

286-
- `IS_LENGTH` checks whether a class part is a number (`3`, `1.5`), a fraction (`3/4`), a arbitrary length (`[3%]`, `[4px]`, `[length:var(--my-var)]`), or one of the strings `px`, `full` or `screen`.
287-
- `IS_ARBITRARY_LENGTH` checks for arbitrary length values (`[3%]`, `[4px]`, `[length:var(--my-var)]`).
288-
- `IS_INTEGER` checks for integer values (`3`) and arbitrary integer values (`[3]`).
289-
- `IS_ARBITRARY_VALUE` checks whether the class part is enclosed in brackets (`[something]`)
290-
- `IS_TSHIRT_SIZE`checks whether class part is a T-shirt size (`sm`, `xl`), optionally with a preceding number (`2xl`).
291-
- `IS_ARBITRARY_SIZE` checks whether class part is an arbitrary value which starts with `size:` (`[size:200px_100px]`) which is necessary for background-size classNames.
292-
- `IS_ARBITRARY_POSITION` checks whether class part is an arbitrary value which starts with `position:` (`[position:200px_100px]`) which is necessary for background-position classNames.
293-
- `IS_ARBITRARY_URL` checks whether class part is an arbitrary value which starts with `url:` or `url(` (`[url('/path-to-image.png')]`, `url:var(--maybe-a-url-at-runtime)]`) which is necessary for background-image classNames.
294-
- `IS_ARBITRARY_NUMBER` checks whether class part is an arbitrary value which starts with `number:` or is a number (`[number:var(--value)]`, `[450]`) which is necessary for font-weight classNames.
295-
- `IS_ARBITRARY_SHADOW` checks whether class part is an arbitrary value which starts with the same pattern as a shadow value (`[0_35px_60px_-15px_rgba(0,0,0,0.3)]`), namely with two lengths separated by a underscore.
296-
- `IS_ANY` always returns true. Be careful with this validator as it might match unwanted classes. I use it primarily to match colors or when it's certain there are no other class groups in a namespace.
286+
- `IS_LENGTH` checks whether a class part is a number (`3`, `1.5`), a fraction (`3/4`), a arbitrary length (`[3%]`, `[4px]`, `[length:var(--my-var)]`), or one of the strings `px`, `full` or `screen`.
287+
- `IS_ARBITRARY_LENGTH` checks for arbitrary length values (`[3%]`, `[4px]`, `[length:var(--my-var)]`).
288+
- `IS_INTEGER` checks for integer values (`3`) and arbitrary integer values (`[3]`).
289+
- `IS_ARBITRARY_VALUE` checks whether the class part is enclosed in brackets (`[something]`)
290+
- `IS_TSHIRT_SIZE`checks whether class part is a T-shirt size (`sm`, `xl`), optionally with a preceding number (`2xl`).
291+
- `IS_ARBITRARY_SIZE` checks whether class part is an arbitrary value which starts with `size:` (`[size:200px_100px]`) which is necessary for background-size classNames.
292+
- `IS_ARBITRARY_POSITION` checks whether class part is an arbitrary value which starts with `position:` (`[position:200px_100px]`) which is necessary for background-position classNames.
293+
- `IS_ARBITRARY_URL` checks whether class part is an arbitrary value which starts with `url:` or `url(` (`[url('/path-to-image.png')]`, `url:var(--maybe-a-url-at-runtime)]`) which is necessary for background-image classNames.
294+
- `IS_ARBITRARY_NUMBER` checks whether class part is an arbitrary value which starts with `number:` or is a number (`[number:var(--value)]`, `[450]`) which is necessary for font-weight classNames.
295+
- `IS_ARBITRARY_SHADOW` checks whether class part is an arbitrary value which starts with the same pattern as a shadow value (`[0_35px_60px_-15px_rgba(0,0,0,0.3)]`), namely with two lengths separated by a underscore.
296+
- `IS_ANY` always returns true. Be careful with this validator as it might match unwanted classes. I use it primarily to match colors or when it's certain there are no other class groups in a namespace.
297297

298298
## Contributing
299299

lib/tailwind_merge.rb

Lines changed: 4 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,17 @@
1111
require_relative "tailwind_merge/validators"
1212
require_relative "tailwind_merge/config"
1313
require_relative "tailwind_merge/class_utils"
14+
require_relative "tailwind_merge/modifier_utils"
1415

1516
require "strscan"
1617
require "set"
1718

1819
module TailwindMerge
1920
class Merger
2021
include Config
22+
include ModifierUtils
2123

2224
SPLIT_CLASSES_REGEX = /\s+/
23-
IMPORTANT_MODIFIER = "!"
2425

2526
def initialize(config: {})
2627
@config = if config.fetch(:theme, nil)
@@ -48,7 +49,7 @@ def merge(classes)
4849
class_groups_in_conflict = Set.new
4950

5051
classes.strip.split(SPLIT_CLASSES_REGEX).map do |original_class_name|
51-
modifiers, has_important_modifier, base_class_name = split_modifiers(original_class_name)
52+
modifiers, has_important_modifier, base_class_name = split_modifiers(original_class_name, separator: @config[:separator])
5253

5354
class_group_id = @class_utils.class_group_id(base_class_name)
5455

@@ -59,7 +60,7 @@ def merge(classes)
5960
}
6061
end
6162

62-
variant_modifier = sort_modifiers(modifiers).join("")
63+
variant_modifier = sort_modifiers(modifiers).join(":")
6364

6465
modifier_id = has_important_modifier ? "#{variant_modifier}#{IMPORTANT_MODIFIER}" : variant_modifier
6566

@@ -89,67 +90,5 @@ def merge(classes)
8990
true
9091
end.reverse.map { |parsed| parsed[:original_class_name] }.join(" ")
9192
end
92-
93-
SPLIT_MODIFIER_REGEX = /[:\[\]]/
94-
private def split_modifiers(class_name)
95-
modifiers = []
96-
97-
bracket_depth = 0
98-
modifier_start = 0
99-
100-
ss = StringScanner.new(class_name)
101-
102-
until ss.eos?
103-
portion = ss.scan_until(SPLIT_MODIFIER_REGEX)
104-
105-
if portion.nil?
106-
ss.terminate
107-
next
108-
end
109-
pos = ss.pos - 1
110-
if class_name[pos] == ":" && bracket_depth.zero?
111-
next_modifier_start = pos
112-
modifiers << class_name[modifier_start..next_modifier_start]
113-
modifier_start = next_modifier_start + 1
114-
elsif class_name[pos] == "["
115-
bracket_depth += 1
116-
elsif class_name[pos] == "]"
117-
bracket_depth -= 1
118-
end
119-
end
120-
121-
base_class_name_with_important_modifier = modifiers.empty? ? class_name : class_name[modifier_start..-1]
122-
has_important_modifier = base_class_name_with_important_modifier.start_with?(IMPORTANT_MODIFIER)
123-
base_class_name = has_important_modifier ? base_class_name_with_important_modifier[1..-1] : base_class_name_with_important_modifier
124-
125-
[modifiers, has_important_modifier, base_class_name]
126-
end
127-
128-
# Sorts modifiers according to following schema:
129-
# - Predefined modifiers are sorted alphabetically
130-
# - When an arbitrary variant appears, it must be preserved which modifiers are before and after it
131-
private def sort_modifiers(modifiers)
132-
if modifiers.length <= 1
133-
return modifiers
134-
end
135-
136-
sorted_modifiers = []
137-
unsorted_modifiers = []
138-
139-
modifiers.each do |modifier|
140-
is_arbitrary_variant = modifier[0] == "["
141-
142-
if is_arbitrary_variant
143-
sorted_modifiers.push(unsorted_modifiers.sort, modifier)
144-
unsorted_modifiers = []
145-
else
146-
unsorted_modifiers.push(modifier)
147-
end
148-
end
149-
150-
sorted_modifiers.push(...unsorted_modifiers.sort)
151-
152-
sorted_modifiers
153-
end
15493
end
15594
end

lib/tailwind_merge/config.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ module Config
106106

107107
DEFAULTS = {
108108
cache_size: 500,
109+
separator: ":",
109110
theme: {
110111
"colors" => [IS_ANY],
111112
"spacing" => [IS_LENGTH],

lib/tailwind_merge/modifier_utils.rb

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# frozen_string_literal: true
2+
3+
module TailwindMerge
4+
module ModifierUtils
5+
IMPORTANT_MODIFIER = "!"
6+
7+
def split_modifiers(class_name, separator: nil)
8+
separator ||= ":"
9+
separator_length = separator.length
10+
11+
modifiers = []
12+
bracket_depth = 0
13+
modifier_start = 0
14+
15+
class_name.each_char.with_index do |char, index|
16+
if bracket_depth.zero? && char == separator[0]
17+
if separator_length == 1 || class_name[index..(index + separator_length - 1)] == separator
18+
modifiers << class_name[modifier_start..index]
19+
modifier_start = index + separator_length
20+
end
21+
end
22+
23+
if class_name[index] == "["
24+
bracket_depth += 1
25+
elsif class_name[index] == "]"
26+
bracket_depth -= 1
27+
end
28+
end
29+
30+
base_class_name_with_important_modifier = modifiers.empty? ? class_name : class_name[modifier_start..-1]
31+
has_important_modifier = base_class_name_with_important_modifier.start_with?(IMPORTANT_MODIFIER)
32+
base_class_name = has_important_modifier ? base_class_name_with_important_modifier[1..-1] : base_class_name_with_important_modifier
33+
34+
[modifiers, has_important_modifier, base_class_name]
35+
end
36+
37+
# Sorts modifiers according to following schema:
38+
# - Predefined modifiers are sorted alphabetically
39+
# - When an arbitrary variant appears, it must be preserved which modifiers are before and after it
40+
def sort_modifiers(modifiers)
41+
if modifiers.length <= 1
42+
return modifiers
43+
end
44+
45+
sorted_modifiers = []
46+
unsorted_modifiers = []
47+
48+
modifiers.each do |modifier|
49+
is_arbitrary_variant = modifier[0] == "["
50+
51+
if is_arbitrary_variant
52+
sorted_modifiers.push(unsorted_modifiers.sort, modifier)
53+
unsorted_modifiers = []
54+
else
55+
unsorted_modifiers.push(modifier)
56+
end
57+
end
58+
59+
sorted_modifiers.push(...unsorted_modifiers.sort)
60+
61+
sorted_modifiers
62+
end
63+
end
64+
end

lib/tailwind_merge/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
module TailwindMerge
4-
VERSION = "0.3.1"
4+
VERSION = "0.4.0"
55
end

test/test_seperator.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
class TestSeparator < Minitest::Test
6+
def test_single_character_separator_working_correctly
7+
merger = TailwindMerge::Merger.new(config: { separator: "_" })
8+
9+
assert_equal("hidden", merger.merge("block hidden"))
10+
assert_equal("p-2", merger.merge("p-3 p-2"))
11+
assert_equal("!inset-0", merger.merge("!right-0 !inset-0"))
12+
assert_equal("focus_hover_!inset-0", merger.merge("hover_focus_!right-0 focus_hover_!inset-0"))
13+
assert_equal("hover:focus:!right-0 focus:hover:!inset-0", merger.merge("hover:focus:!right-0 focus:hover:!inset-0"))
14+
end
15+
16+
def test_multiple_character_separator_working_correctly
17+
merger = TailwindMerge::Merger.new(config: { separator: "__" })
18+
19+
assert_equal("hidden", merger.merge("block hidden"))
20+
assert_equal("p-2", merger.merge("p-3 p-2"))
21+
assert_equal("!inset-0", merger.merge("!right-0 !inset-0"))
22+
assert_equal("focus__hover__!inset-0", merger.merge("hover__focus__!right-0 focus__hover__!inset-0"))
23+
assert_equal("hover:focus:!right-0 focus:hover:!inset-0", merger.merge("hover:focus:!right-0 focus:hover:!inset-0"))
24+
end
25+
end

test/test_tailwind_merge.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def test_it_basically_works
2424
def test_removes_duplicates
2525
original = "bg-red-500 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm border-indigo-500 text-indigo-600 bg-red-500 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
2626
merged = "bg-red-500 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
27+
2728
refute_equal(original, @merger.merge(original))
2829
assert_equal(merged, @merger.merge(original))
2930
end

0 commit comments

Comments
 (0)