Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow hooking into class parsing logic (experimental) #444

Merged
merged 8 commits into from
Jul 7, 2024
6 changes: 4 additions & 2 deletions docs/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ This package follows the [SemVer](https://semver.org) versioning rules. More spe

- Major version gets incremented when breaking changes are introduced to the package API. E.g. the return type of `twMerge` changes.

- `alpha` releases might introduce breaking changes on any update. Whereas `beta` releases only introduce new features or bug fixes.
- `alpha` releases might introduce breaking changes on any update. `beta` releases intend to only introduce new features or bug fixes, but can introduce breaking changes in rare cases.

- Any API that has `experimental` in its name can introduce breaking changes in any minor version update.

- Releases with major version 0 might introduce breaking changes on a minor version update.

- A non-production-ready version of every commit pushed to the main branch is released under the `dev` tag for testing purposes. It has a format like [`1.6.1-dev.4202ccf913525617f19fbc493db478a76d64d054`](https://www.npmjs.com/package/tailwind-merge/v/1.6.1-dev.4202ccf913525617f19fbc493db478a76d64d054) in which the first numbers are the corresponding last release and the hash at the end is the git SHA of the commit. You can install the latest dev release with `yarn add tailwind-merge@dev`.
- A non-production-ready version of every commit pushed to the main branch is released under the `dev` tag for testing purposes. It has a format like [`1.6.1-dev.4202ccf913525617f19fbc493db478a76d64d054`](https://www.npmjs.com/package/tailwind-merge/v/1.6.1-dev.4202ccf913525617f19fbc493db478a76d64d054) in which the first numbers are the corresponding last release and the hash at the end is the git SHA of the commit. You can install the latest dev release with `npm install tailwind-merge@dev`.

- A changelog is documented in [GitHub Releases](https://github.com/dcastil/tailwind-merge/releases).

Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ export {
type Config,
type DefaultClassGroupIds,
type DefaultThemeGroupIds,
type ExperimentalParseClassNameParam,
type ExperimentalParsedClassName,
} from './lib/types'
export * as validators from './lib/validators'
2 changes: 1 addition & 1 deletion src/lib/class-utils.ts → src/lib/class-group-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ interface ClassValidatorObject {

const CLASS_PART_SEPARATOR = '-'

export function createClassUtils(config: GenericConfig) {
export function createClassGroupUtils(config: GenericConfig) {
const classMap = createClassMap(config)
const { conflictingClassGroups, conflictingClassGroupModifiers } = config

Expand Down
8 changes: 4 additions & 4 deletions src/lib/config-utils.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { createClassUtils } from './class-utils'
import { createClassGroupUtils } from './class-group-utils'
import { createLruCache } from './lru-cache'
import { createSplitModifiers } from './modifier-utils'
import { createParseClassName } from './parse-class-name'
import { GenericConfig } from './types'

export type ConfigUtils = ReturnType<typeof createConfigUtils>

export function createConfigUtils(config: GenericConfig) {
return {
cache: createLruCache<string, string>(config.cacheSize),
splitModifiers: createSplitModifiers(config),
...createClassUtils(config),
parseClassName: createParseClassName(config),
...createClassGroupUtils(config),
}
}
13 changes: 6 additions & 7 deletions src/lib/merge-classlist.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ConfigUtils } from './config-utils'
import { IMPORTANT_MODIFIER, sortModifiers } from './modifier-utils'
import { IMPORTANT_MODIFIER, sortModifiers } from './parse-class-name'

const SPLIT_CLASSES_REGEX = /\s+/

export function mergeClassList(classList: string, configUtils: ConfigUtils) {
const { splitModifiers, getClassGroupId, getConflictingClassGroupIds } = configUtils
const { parseClassName, getClassGroupId, getConflictingClassGroupIds } = configUtils

/**
* Set of classGroupIds in following format:
Expand All @@ -25,18 +25,17 @@ export function mergeClassList(classList: string, configUtils: ConfigUtils) {
hasImportantModifier,
baseClassName,
maybePostfixModifierPosition,
} = splitModifiers(originalClassName)
} = parseClassName(originalClassName)

let hasPostfixModifier = Boolean(maybePostfixModifierPosition)
let classGroupId = getClassGroupId(
maybePostfixModifierPosition
hasPostfixModifier
? baseClassName.substring(0, maybePostfixModifierPosition)
: baseClassName,
)

let hasPostfixModifier = Boolean(maybePostfixModifierPosition)

if (!classGroupId) {
if (!maybePostfixModifierPosition) {
if (!hasPostfixModifier) {
return {
isTailwindClass: false as const,
originalClassName,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/merge-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ export function mergeConfigs<ClassGroupIds extends string, ThemeGroupIds extends
cacheSize,
prefix,
separator,
experimentalParseClassName,
extend = {},
override = {},
}: ConfigExtension<ClassGroupIds, ThemeGroupIds>,
) {
overrideProperty(baseConfig, 'cacheSize', cacheSize)
overrideProperty(baseConfig, 'prefix', prefix)
overrideProperty(baseConfig, 'separator', separator)
overrideProperty(baseConfig, 'experimentalParseClassName', experimentalParseClassName)

for (const configKey in override) {
overrideConfigProperties(
Expand Down
16 changes: 12 additions & 4 deletions src/lib/modifier-utils.ts → src/lib/parse-class-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@ import { GenericConfig } from './types'

export const IMPORTANT_MODIFIER = '!'

export function createSplitModifiers(config: GenericConfig) {
const separator = config.separator
export function createParseClassName(config: GenericConfig) {
const { separator, experimentalParseClassName } = config
const isSeparatorSingleCharacter = separator.length === 1
const firstSeparatorCharacter = separator[0]
const separatorLength = separator.length

// splitModifiers inspired by https://github.com/tailwindlabs/tailwindcss/blob/v3.2.2/src/util/splitAtTopLevelOnly.js
return function splitModifiers(className: string) {
// parseClassName inspired by https://github.com/tailwindlabs/tailwindcss/blob/v3.2.2/src/util/splitAtTopLevelOnly.js
function parseClassName(className: string) {
const modifiers = []

let bracketDepth = 0
Expand Down Expand Up @@ -63,6 +63,14 @@ export function createSplitModifiers(config: GenericConfig) {
maybePostfixModifierPosition,
}
}

if (experimentalParseClassName) {
return function parseClassNameExperimental(className: string) {
return experimentalParseClassName({ className, parseClassName })
}
}

return parseClassName
}

/**
Expand Down
54 changes: 54 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,60 @@ interface ConfigStatic {
* @see https://tailwindcss.com/docs/configuration#separator
*/
separator: string
/**
* Allows to customize parsing of individual classes passed to `twMerge`.
* All classes passed to `twMerge` outside of cache hits are passed to this function before it is determined whether the class is a valid Tailwind CSS class.
*
* This is an experimental feature and may introduce breaking changes in any minor version update.
*/
experimentalParseClassName?(param: ExperimentalParseClassNameParam): ExperimentalParsedClassName
}

/**
* Type of param passed to the `experimentalParseClassName` function.
*
* This is an experimental feature and may introduce breaking changes in any minor version update.
*/
export interface ExperimentalParseClassNameParam {
className: string
parseClassName(className: string): ExperimentalParsedClassName
}

/**
* Type of the result returned by the `experimentalParseClassName` function.
*
* This is an experimental feature and may introduce breaking changes in any minor version update.
*/
export interface ExperimentalParsedClassName {
/**
* Modifiers of the class in the order they appear in the class.
*
* @example ['hover', 'dark'] // for `hover:dark:bg-gray-100`
*/
modifiers: string[]
/**
* Whether the class has an `!important` modifier.
*
* @example true // for `hover:dark:!bg-gray-100`
*/
hasImportantModifier: boolean
/**
* Base class without preceding modifiers.
*
* @example 'bg-gray-100' // for `hover:dark:bg-gray-100`
*/
baseClassName: string
/**
* Index position of a possible postfix modifier in the class.
* If the class has no postfix modifier, this is `undefined`.
*
* This property is prefixed with "maybe" because tailwind-merge does not know whether something is a postfix modifier or part of the base class since it's possible to configure Tailwind CSS classes which include a `/` in the base class name.
*
* If a `maybePostfixModifierPosition` is present, tailwind-merge first tries to match the `baseClassName` without the possible postfix modifier to a class group. If tht fails, it tries again with the possible postfix modifier.
*
* @example 11 // for `bg-gray-100/50`
*/
maybePostfixModifierPosition: number | undefined
}

interface ConfigGroups<ClassGroupIds extends string, ThemeGroupIds extends string> {
Expand Down
2 changes: 1 addition & 1 deletion tests/class-map.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getDefaultConfig } from '../src'
import { ClassPartObject, createClassMap } from '../src/lib/class-utils'
import { ClassPartObject, createClassMap } from '../src/lib/class-group-utils'

test('class map has correct class groups at first part', () => {
const classMap = createClassMap(getDefaultConfig())
Expand Down
37 changes: 37 additions & 0 deletions tests/experimental-parse-class-name.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { extendTailwindMerge } from '../src'

test('default case', () => {
const twMerge = extendTailwindMerge({
experimentalParseClassName({ className, parseClassName }) {
return parseClassName(className)
},
})

expect(twMerge('px-2 py-1 p-3')).toBe('p-3')
})

test('removing first three characters from class', () => {
const twMerge = extendTailwindMerge({
experimentalParseClassName({ className, parseClassName }) {
return parseClassName(className.slice(3))
},
})

expect(twMerge('barpx-2 foopy-1 lolp-3')).toBe('lolp-3')
})

test('ignoring breakpoint modifiers', () => {
const breakpoints = new Set(['sm', 'md', 'lg', 'xl', '2xl'])
const twMerge = extendTailwindMerge({
experimentalParseClassName({ className, parseClassName }) {
const parsed = parseClassName(className)

return {
...parsed,
modifiers: parsed.modifiers.filter((modifier) => !breakpoints.has(modifier)),
}
},
})

expect(twMerge('md:px-2 hover:py-4 py-1 lg:p-3')).toBe('hover:py-4 lg:p-3')
})
Loading