Skip to content

Commit

Permalink
Merge pull request #444 from dcastil/feature/440/allow-hooking-into-c…
Browse files Browse the repository at this point in the history
…lass-parsing

Allow hooking into class parsing logic (experimental)
  • Loading branch information
dcastil authored Jul 7, 2024
2 parents a51bec8 + 9aa1c8e commit 3847cc0
Show file tree
Hide file tree
Showing 10 changed files with 123 additions and 19 deletions.
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')
})

0 comments on commit 3847cc0

Please sign in to comment.