diff --git a/pkg/twmerge/class-utils.go b/pkg/twmerge/class-utils.go new file mode 100644 index 0000000..3cd892b --- /dev/null +++ b/pkg/twmerge/class-utils.go @@ -0,0 +1,73 @@ +package twmerge + +import ( + "regexp" + "strings" +) + +type GetClassGroupIdfn func(string) (isTwClass bool, groupId string) + +func MakeGetClassGroupId(conf *TwMergeConfig) GetClassGroupIdfn { + var getClassGroupIdRecursive func(classParts []string, i int, classMap *ClassPart) (isTwClass bool, groupId string) + getClassGroupIdRecursive = func(classParts []string, i int, classMap *ClassPart) (isTwClass bool, groupId string) { + if i >= len(classParts) { + if classMap.ClassGroupId != "" { + return true, classMap.ClassGroupId + } + + return false, "" + } + + if classMap.NextPart != nil { + nextClassMap := classMap.NextPart[classParts[i]] + isTw, id := getClassGroupIdRecursive(classParts, i+1, &nextClassMap) + if isTw { + return isTw, id + } + } + + if classMap.Validators != nil && len(classMap.Validators) > 0 { + remainingClass := strings.Join(classParts[i:], string(conf.ClassSeparator)) + + for _, validator := range classMap.Validators { + if validator.Fn(remainingClass) { + return true, validator.ClassGroupId + } + } + + } + return false, "" + } + + var arbitraryPropertyRegex = regexp.MustCompile(`^\[(.+)\]$`) + + getGroupIdForArbitraryProperty := func(class string) (bool, string) { + if arbitraryPropertyRegex.MatchString(class) { + arbitraryPropertyClassName := arbitraryPropertyRegex.FindStringSubmatch(class)[1] + property := arbitraryPropertyClassName[:strings.Index(arbitraryPropertyClassName, ":")] + + if property != "" { + // I use two dots here because one dot is used as prefix for class groups in plugins + return true, "arbitrary.." + property + } + } + + return false, "" + } + + return func(baseClass string) (isTwClass bool, groupdId string) { + + classParts := strings.Split(baseClass, string(conf.ClassSeparator)) + // remove first element if empty for things like -px-4 + if len(classParts) > 0 && classParts[0] == "" { + classParts = classParts[1:] + } + isTwClass, groupId := getClassGroupIdRecursive(classParts, 0, &conf.ClassGroups) + if isTwClass { + return isTwClass, groupId + } + + return getGroupIdForArbitraryProperty(baseClass) + } + +} diff --git a/pkg/twmerge/create-tailwind-merge.go b/pkg/twmerge/create-tailwind-merge.go new file mode 100644 index 0000000..c242461 --- /dev/null +++ b/pkg/twmerge/create-tailwind-merge.go @@ -0,0 +1,53 @@ +package twmerge + +import ( + "strings" + + lru "github.com/Oudwins/tailwind-merge-go/pkg/cache" +) + +// create the config (just gets the config passed in) + +// create the config utils +// LRU cache +// split modifiers +// -> for things like hover:bg-x +// class utils +// -> for splitting classes + +// cache get & set + +// merge fn +// 1. check cache +// 2. mergeClassList +// 3. set cache + +// should this also take a cache directly? +func CreateTwMerge(config *TwMergeConfig, cache lru.Cache) func(args ...string) string { + if config == nil { + config = MakeDefaultConfig() + } + if cache == nil { + cache = lru.Make(config.MaxCacheSize) + } + + splitModifiers := MakeSplitModifiers(config) + + getClassGroupId := MakeGetClassGroupId(config) + + mergeClassList := MakeMergeClassList(config, splitModifiers, getClassGroupId) + + return func(args ...string) string { + classList := strings.Join(args, " ") + cached := cache.Get(classList) + if cached != "" { + return cached + } + // check if in cache + merged := mergeClassList(classList) + cache.Set(classList, merged) + return merged + } +} + +var Merge = CreateTwMerge(nil, nil) diff --git a/pkg/twmerge/merge-classlist.go b/pkg/twmerge/merge-classlist.go new file mode 100644 index 0000000..09af354 --- /dev/null +++ b/pkg/twmerge/merge-classlist.go @@ -0,0 +1,88 @@ +package twmerge + +import ( + "regexp" + "slices" + "strings" +) + +const SPLIT_CLASSES_REGEX = `\s+` + +var splitPattern = regexp.MustCompile(SPLIT_CLASSES_REGEX) + +func MakeMergeClassList(conf *TwMergeConfig, splitModifiers SplitModifiersFn, getClassGroupId GetClassGroupIdfn) func(classList string) string { + return func(classList string) string { + classes := splitPattern.Split(strings.TrimSpace(classList), -1) + unqClasses := make(map[string]string, len(classes)) + resultClassList := "" + + for _, class := range classes { + baseClass, modifiers, hasImportant, maybePostfixModPosition := splitModifiers(class) + + // there is a postfix modifier -> text-lg/8 + if maybePostfixModPosition != -1 { + baseClass = baseClass[:maybePostfixModPosition] + } + isTwClass, groupId := getClassGroupId(baseClass) + if !isTwClass { + resultClassList += class + " " + continue + } + // we have to sort the modifiers bc hover:focus:bg-red-500 == focus:hover:bg-red-500 + modifiers = SortModifiers(modifiers) + if hasImportant { + modifiers = append(modifiers, "!") + } + unqClasses[groupId+strings.Join(modifiers, string(conf.ModifierSeparator))] = class + + conflicts := conf.ConflictingClassGroups[groupId] + if conflicts == nil { + continue + } + for _, conflict := range conflicts { + // erase the conflicts with the same modifiers + unqClasses[conflict+strings.Join(modifiers, string(conf.ModifierSeparator))] = "" + } + } + + for _, class := range unqClasses { + if class == "" { + continue + } + resultClassList += class + " " + } + return strings.TrimSpace(resultClassList) + } + +} + +/** + * Sorts modifiers according to following schema: + * - Predefined modifiers are sorted alphabetically + * - When an arbitrary variant appears, it must be preserved which modifiers are before and after it + */ +func SortModifiers(modifiers []string) []string { + if modifiers == nil || len(modifiers) < 2 { + return modifiers + } + + unsortedModifiers := []string{} + sorted := make([]string, len(modifiers)) + + for _, modifier := range modifiers { + isArbitraryVariant := modifier[0] == '[' + if isArbitraryVariant { + slices.Sort(unsortedModifiers) + sorted = append(sorted, unsortedModifiers...) + sorted = append(sorted, modifier) + unsortedModifiers = []string{} + continue + } + unsortedModifiers = append(unsortedModifiers, modifier) + } + + slices.Sort(unsortedModifiers) + sorted = append(sorted, unsortedModifiers...) + + return sorted +} diff --git a/pkg/twmerge/modifier-utils.go b/pkg/twmerge/modifier-utils.go new file mode 100644 index 0000000..315eabf --- /dev/null +++ b/pkg/twmerge/modifier-utils.go @@ -0,0 +1,52 @@ +package twmerge + +type SplitModifiersFn = func(string) (baseClass string, modifiers []string, hasImportant bool, maybePostfixModPosition int) + +func MakeSplitModifiers(conf *TwMergeConfig) SplitModifiersFn { + separator := conf.ModifierSeparator + + return func(className string) (string, []string, bool, int) { + modifiers := []string{} + modifierStart := 0 + bracketDepth := 0 + // used for bg-red-500/50 (50% opacity) + maybePostfixModPosition := -1 + + for i := 0; i < len(className); i++ { + char := rune(className[i]) + + if char == '[' { + bracketDepth++ + continue + } + if char == ']' { + bracketDepth-- + continue + } + + if bracketDepth == 0 { + if char == separator { + modifiers = append(modifiers, className[modifierStart:i]) + modifierStart = i + 1 + continue + } + + if char == conf.PostfixModifier { + maybePostfixModPosition = i + } + } + } + + baseClassWithImportant := className[modifierStart:] + hasImportant := baseClassWithImportant[0] == byte(conf.ImportantModifier) + var baseClass string + if hasImportant { + baseClass = baseClassWithImportant[1:] + } else { + baseClass = baseClassWithImportant + } + + return baseClass, modifiers, hasImportant, maybePostfixModPosition + + } +} diff --git a/pkg/twmerge/validators.go b/pkg/twmerge/validators.go new file mode 100644 index 0000000..24ba460 --- /dev/null +++ b/pkg/twmerge/validators.go @@ -0,0 +1,139 @@ +package twmerge + +import ( + "regexp" + "strconv" +) + +var stringLengths = map[string]bool{ + "px": true, + "full": true, + "screen": true, +} + +var lengthUnitRegex = regexp.MustCompile(`\d+(%|px|r?em|[sdl]?v([hwib]|min|max)|pt|pc|in|cm|mm|cap|ch|ex|r?lh|cq(w|h|i|b|min|max))|\b(calc|min|max|clamp)\(.+\)|^0$`) +var colorFnRegex = regexp.MustCompile(`^(rgba?|hsla?|hwb|(ok)?(lab|lch))\(.+\)$`) + +func IsAny(_ string) bool { + return true +} + +func IsNever(_ string) bool { + return false +} + +func IsLength(val string) bool { + if IsNumber(val) || stringLengths[val] || IsFraction(val) { + return true + } + return false +} + +func IsArbitraryLength(val string) bool { + return GetIsArbitraryValue(val, "length", IsLengthOnly) +} + +var arbitraryRegex = regexp.MustCompile(`(?i)^\[(?:([a-z-]+):)?(.+)\]$`) + +func IsArbitraryNumber(val string) bool { + return GetIsArbitraryValue(val, "number", IsNumber) +} + +func IsArbitraryPosition(val string) bool { + return GetIsArbitraryValue(val, "position", IsNever) +} + +var sizeLabels = map[string]bool{ + "length": true, "size": true, "percentage": true, +} + +func IsArbitrarySize(val string) bool { + return GetIsArbitraryValue(val, sizeLabels, IsNever) +} + +var imageLabels = map[string]bool{ + "image": true, "url": true, +} + +func IsArbitraryImage(val string) bool { + return GetIsArbitraryValue(val, imageLabels, IsImage) +} +func IsArbitraryShadow(val string) bool { + return GetIsArbitraryValue(val, "", IsShadow) +} + +func IsArbitraryValue(val string) bool { + return arbitraryRegex.MatchString(val) +} + +func IsPercent(val string) bool { + return val[len(val)-1] == '%' && IsNumber(val[:len(val)-1]) +} + +func IsTshirtSize(val string) bool { + pattern := regexp.MustCompile(`^(\d+(\.\d+)?)?(xs|sm|md|lg|xl)$`) + return pattern.MatchString(val) +} + +func IsShadow(val string) bool { + pattern := regexp.MustCompile(`^-?((\d+)?\.?(\d+)[a-z]+|0)_-?((\d+)?\.?(\d+)[a-z]+|0)`) + return pattern.MatchString(val) +} + +func IsImage(val string) bool { + pattern := regexp.MustCompile(`^(url|image|image-set|cross-fade|element|(repeating-)?(linear|radial|conic)-gradient)\(.+\)$`) + return pattern.MatchString(val) +} + +func IsFraction(val string) bool { + pattern := regexp.MustCompile(`^\d+\/\d+$`) + return pattern.MatchString(val) +} + +func IsNumber(val string) bool { + return IsInteger(val) || IsFloat(val) +} + +func IsInteger(val string) bool { + _, err := strconv.Atoi(val) + if err != nil { + return false + } + return true +} + +func IsFloat(val string) bool { + _, err := strconv.ParseFloat(val, 64) + if err != nil { + return false + } + return true +} + +func IsLengthOnly(val string) bool { + return lengthUnitRegex.MatchString(val) && !colorFnRegex.MatchString(val) +} + +func GetIsArbitraryValue(val string, label interface{}, testValue func(string) bool) bool { + res := arbitraryRegex.FindStringSubmatch(val) + + if len(res) > 1 { + if res[1] != "" { + + if t, ok := label.(string); ok { + return res[1] == t + } + + if t, ok := label.(map[string]bool); ok { + return t[res[1]] + } + } + + if len(res) > 2 { + return testValue(res[2]) + } + + } + + return false +}