tailwind_merge 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module TailwindMerge
6
+ module Validators
7
+ class << self
8
+ def numeric?(x)
9
+ Float(x, exception: false).is_a?(Numeric)
10
+ end
11
+
12
+ def integer?(x)
13
+ Integer(x, exception: false).is_a?(Integer)
14
+ end
15
+ end
16
+
17
+ STRING_LENGTHS = Set.new(["px", "full", "screen"]).freeze
18
+
19
+ ARBITRARY_VALUE_REGEX = /^\[(.+)\]$/
20
+ FRACTION_REGEX = %r{^\d+/\d+$}
21
+ LENGTH_UNIT_REGEX = /\d+(%|px|em|rem|vh|vw|pt|pc|in|cm|mm|cap|ch|ex|lh|rlh|vi|vb|vmin|vmax)/
22
+ TSHIRT_UNIT_REGEX = /^(\d+)?(xs|sm|md|lg|xl)$/
23
+ # Shadow always begins with x and y offset separated by underscore
24
+ SHADOW_REGEX = /^-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)/
25
+
26
+ IS_ANY = ->(_) { return true }
27
+
28
+ IS_LENGTH = ->(class_part) {
29
+ numeric?(class_part) || \
30
+ STRING_LENGTHS.include?(class_part) || \
31
+ FRACTION_REGEX.match?(class_part) || \
32
+ IS_ARBITRARY_LENGTH.call(class_part)
33
+ }
34
+
35
+ IS_ARBITRARY_LENGTH = ->(class_part) {
36
+ if (match = ARBITRARY_VALUE_REGEX.match(class_part))
37
+ return match[1].start_with?("length:") || LENGTH_UNIT_REGEX.match?(class_part)
38
+ end
39
+
40
+ false
41
+ }
42
+
43
+ IS_ARBITRARY_SIZE = ->(class_part) {
44
+ if (match = ARBITRARY_VALUE_REGEX.match(class_part))
45
+ return match[1].start_with?("size:")
46
+ end
47
+
48
+ false
49
+ }
50
+
51
+ IS_ARBITRARY_POSITION = ->(class_part) {
52
+ if (match = ARBITRARY_VALUE_REGEX.match(class_part))
53
+ return match[1].start_with?("position:")
54
+ end
55
+
56
+ false
57
+ }
58
+
59
+ IS_ARBITRARY_VALUE = ->(class_part) {
60
+ ARBITRARY_VALUE_REGEX.match(class_part)
61
+ }
62
+
63
+ IS_ARBITRARY_URL = ->(class_part) {
64
+ if (match = ARBITRARY_VALUE_REGEX.match(class_part))
65
+ return match[1].start_with?("url(", "url:")
66
+ end
67
+
68
+ false
69
+ }
70
+
71
+ IS_ARBITRARY_WEIGHT = ->(class_part) {
72
+ if (match = ARBITRARY_VALUE_REGEX.match(class_part))
73
+ return match[1].start_with?("number:") || numeric?(match[1])
74
+ end
75
+
76
+ false
77
+ }
78
+
79
+ IS_TSHIRT_SIZE = ->(class_part) {
80
+ TSHIRT_UNIT_REGEX.match?(class_part)
81
+ }
82
+
83
+ IS_INTEGER = ->(class_part) {
84
+ if (match = ARBITRARY_VALUE_REGEX.match(class_part))
85
+ return integer?(match[1])
86
+ end
87
+
88
+ integer?(class_part)
89
+ }
90
+
91
+ IS_ARBITRARY_SHADOW = ->(class_part) {
92
+ if (match = ARBITRARY_VALUE_REGEX.match(class_part))
93
+ return SHADOW_REGEX.match?(match[1])
94
+ end
95
+
96
+ return false
97
+ }
98
+ end
99
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TailwindMerge
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ if ENV.fetch("DEBUG", false)
4
+ require "amazing_print"
5
+ require "debug"
6
+ end
7
+
8
+ require "lru_redux"
9
+
10
+ require_relative "tailwind_merge/version"
11
+ require_relative "tailwind_merge/validators"
12
+ require_relative "tailwind_merge/config"
13
+ require_relative "tailwind_merge/class_utils"
14
+
15
+ require "strscan"
16
+ require "set"
17
+
18
+ module TailwindMerge
19
+ class Merger
20
+ include Config
21
+
22
+ SPLIT_CLASSES_REGEX = /\s+/
23
+ IMPORTANT_MODIFIER = "!"
24
+
25
+ def initialize(config: {})
26
+ @config = if config.fetch(:theme, nil)
27
+ merge_configs(config)
28
+ else
29
+ TailwindMerge::Config::DEFAULTS.merge(config)
30
+ end
31
+
32
+ @class_utils = TailwindMerge::ClassUtils.new(@config)
33
+ @cache = LruRedux::Cache.new(@config[:cache_size])
34
+ end
35
+
36
+ def merge(classes)
37
+ return merge_class_list(classes) unless has_cache?
38
+
39
+ @cache.getset(classes) do
40
+ merge_class_list(classes)
41
+ end
42
+ end
43
+
44
+ def has_cache?
45
+ @cache.nil?
46
+ end
47
+
48
+ private def merge_class_list(classes)
49
+ # Set of class_group_ids in following format:
50
+ # `{importantModifier}{variantModifiers}{classGroupId}`
51
+ # @example 'float'
52
+ # @example 'hover:focus:bg-color'
53
+ # @example 'md:!pr'
54
+ class_groups_in_conflict = Set.new
55
+
56
+ classes.strip.split(SPLIT_CLASSES_REGEX).map do |original_class_name|
57
+ modifiers, has_important_modifier, base_class_name = split_modifiers(original_class_name)
58
+
59
+ class_group_id = @class_utils.class_group_id(base_class_name)
60
+
61
+ unless class_group_id
62
+ next {
63
+ is_tailwind_class: false,
64
+ original_class_name: original_class_name,
65
+ }
66
+ end
67
+
68
+ variant_modifier = sort_modifiers(modifiers).join("")
69
+
70
+ modifier_id = has_important_modifier ? "#{variant_modifier}#{IMPORTANT_MODIFIER}" : variant_modifier
71
+
72
+ {
73
+ is_tailwind_class: true,
74
+ modifier_id: modifier_id,
75
+ class_group_id: class_group_id,
76
+ original_class_name: original_class_name,
77
+ }
78
+ end.reverse # Last class in conflict wins, so filter conflicting classes in reverse order.
79
+ .select do |parsed|
80
+ next(true) unless parsed[:is_tailwind_class]
81
+
82
+ modifier_id = parsed[:modifier_id]
83
+ class_group_id = parsed[:class_group_id]
84
+
85
+ class_id = "#{modifier_id}#{class_group_id}"
86
+
87
+ next if class_groups_in_conflict.include?(class_id)
88
+
89
+ class_groups_in_conflict.add(class_id)
90
+
91
+ @class_utils.get_conflicting_class_group_ids(class_group_id).each do |group|
92
+ class_groups_in_conflict.add("#{modifier_id}#{group}")
93
+ end
94
+
95
+ true
96
+ end.reverse.map { |parsed| parsed[:original_class_name] }.join(" ")
97
+ end
98
+
99
+ private def split_modifiers(class_name)
100
+ modifiers = []
101
+
102
+ bracket_depth = 0
103
+ modifier_start = 0
104
+
105
+ ss = StringScanner.new(class_name)
106
+
107
+ until ss.eos?
108
+ portion = ss.scan_until(/[:\[\]]/)
109
+
110
+ if portion.nil?
111
+ ss.terminate
112
+ next
113
+ end
114
+ pos = ss.pos - 1
115
+ if class_name[pos] == ":" && bracket_depth.zero?
116
+ next_modifier_start = pos
117
+ modifiers << class_name[modifier_start..next_modifier_start]
118
+ modifier_start = next_modifier_start + 1
119
+ elsif class_name[pos] == "["
120
+ bracket_depth += 1
121
+ elsif class_name[pos] == "]"
122
+ bracket_depth -= 1
123
+ end
124
+ end
125
+
126
+ base_class_name_with_important_modifier = modifiers.empty? ? class_name : class_name[modifier_start..-1]
127
+ has_important_modifier = base_class_name_with_important_modifier.start_with?(IMPORTANT_MODIFIER)
128
+ base_class_name = has_important_modifier ? base_class_name_with_important_modifier[1..-1] : base_class_name_with_important_modifier
129
+
130
+ [modifiers, has_important_modifier, base_class_name]
131
+ end
132
+
133
+ # Sorts modifiers according to following schema:
134
+ # - Predefined modifiers are sorted alphabetically
135
+ # - When an arbitrary variant appears, it must be preserved which modifiers are before and after it
136
+ private def sort_modifiers(modifiers)
137
+ if modifiers.length <= 1
138
+ return modifiers
139
+ end
140
+
141
+ sorted_modifiers = []
142
+ unsorted_modifiers = []
143
+
144
+ modifiers.each do |modifier|
145
+ is_arbitrary_variant = modifier[0] == "["
146
+
147
+ if is_arbitrary_variant
148
+ sorted_modifiers.push(unsorted_modifiers.sort, modifier)
149
+ unsorted_modifiers = []
150
+ else
151
+ unsorted_modifiers.push(modifier)
152
+ end
153
+ end
154
+
155
+ sorted_modifiers.push(...unsorted_modifiers.sort)
156
+
157
+ sorted_modifiers
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+
3
+ CHANGELOG_GITHUB_TOKEN="$GH_TOKEN" github_changelog_generator -u gjtorikian -p tailwind_merge
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/tailwind_merge/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "tailwind_merge"
7
+ spec.version = TailwindMerge::VERSION
8
+ spec.authors = ["Garen J. Torikian"]
9
+ spec.email = ["gjtorikian@gmail.com"]
10
+
11
+ spec.summary = "Utility function to efficiently merge Tailwind CSS classes without style conflicts."
12
+ spec.homepage = "https://www.github.com/gjtorikian/tailwind_merge"
13
+ spec.license = "MIT"
14
+
15
+ spec.required_ruby_version = [">= 3.0", "< 4.0"]
16
+
17
+ spec.metadata = {
18
+ "funding_uri" => "https://github.com/sponsors/gjtorikian/",
19
+ "rubygems_mfa_required" => "true",
20
+ }
21
+
22
+ spec.metadata["homepage_uri"] = spec.homepage
23
+
24
+ # Specify which files should be added to the gem when it is released.
25
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
26
+ spec.files = Dir.chdir(__dir__) do
27
+ %x(git ls-files -z).split("\x0").reject do |f|
28
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
29
+ end
30
+ end
31
+ spec.bindir = "exe"
32
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
33
+ spec.require_paths = ["lib"]
34
+
35
+ spec.add_dependency("lru_redux", "~> 1.1")
36
+
37
+ spec.add_development_dependency("amazing_print")
38
+ spec.add_development_dependency("debug") if "#{RbConfig::CONFIG["MAJOR"]}.#{RbConfig::CONFIG["MINOR"]}".to_f >= 3.1
39
+ spec.add_development_dependency("minitest", "~> 5.6")
40
+ spec.add_development_dependency("minitest-focus", "~> 1.1")
41
+ spec.add_development_dependency("rake")
42
+ spec.add_development_dependency("redcarpet")
43
+ spec.add_development_dependency("rubocop-standard")
44
+ end
metadata ADDED
@@ -0,0 +1,175 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tailwind_merge
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Garen J. Torikian
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2022-08-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: lru_redux
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: amazing_print
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: debug
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '5.6'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '5.6'
69
+ - !ruby/object:Gem::Dependency
70
+ name: minitest-focus
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.1'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.1'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: redcarpet
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rubocop-standard
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ description:
126
+ email:
127
+ - gjtorikian@gmail.com
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - ".rubocop.yml"
133
+ - CHANGELOG.md
134
+ - Gemfile
135
+ - LICENSE.txt
136
+ - README.md
137
+ - Rakefile
138
+ - lib/tailwind_merge.rb
139
+ - lib/tailwind_merge/class_utils.rb
140
+ - lib/tailwind_merge/config.rb
141
+ - lib/tailwind_merge/validators.rb
142
+ - lib/tailwind_merge/version.rb
143
+ - script/changelog_generator
144
+ - tailwind_merge.gemspec
145
+ homepage: https://www.github.com/gjtorikian/tailwind_merge
146
+ licenses:
147
+ - MIT
148
+ metadata:
149
+ funding_uri: https://github.com/sponsors/gjtorikian/
150
+ rubygems_mfa_required: 'true'
151
+ homepage_uri: https://www.github.com/gjtorikian/tailwind_merge
152
+ post_install_message:
153
+ rdoc_options: []
154
+ require_paths:
155
+ - lib
156
+ required_ruby_version: !ruby/object:Gem::Requirement
157
+ requirements:
158
+ - - ">="
159
+ - !ruby/object:Gem::Version
160
+ version: '3.0'
161
+ - - "<"
162
+ - !ruby/object:Gem::Version
163
+ version: '4.0'
164
+ required_rubygems_version: !ruby/object:Gem::Requirement
165
+ requirements:
166
+ - - ">="
167
+ - !ruby/object:Gem::Version
168
+ version: '0'
169
+ requirements: []
170
+ rubygems_version: 3.3.7
171
+ signing_key:
172
+ specification_version: 4
173
+ summary: Utility function to efficiently merge Tailwind CSS classes without style
174
+ conflicts.
175
+ test_files: []