@herb-tools/tailwind-class-sorter 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.txt +22 -0
- package/README.md +253 -0
- package/dist/tailwind-class-sorter.cjs +37113 -0
- package/dist/tailwind-class-sorter.cjs.map +1 -0
- package/dist/tailwind-class-sorter.esm.js +37086 -0
- package/dist/tailwind-class-sorter.esm.js.map +1 -0
- package/dist/types/config.d.ts +2 -0
- package/dist/types/expiring-map.d.ts +6 -0
- package/dist/types/index.d.ts +27 -0
- package/dist/types/resolve.d.ts +4 -0
- package/dist/types/sorter.d.ts +53 -0
- package/dist/types/sorting.d.ts +19 -0
- package/dist/types/types.d.ts +40 -0
- package/package.json +61 -0
- package/src/config.ts +344 -0
- package/src/expiring-map.ts +31 -0
- package/src/index.ts +64 -0
- package/src/resolve.ts +76 -0
- package/src/sorter.ts +106 -0
- package/src/sorting.ts +192 -0
- package/src/types.ts +55 -0
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { CachedInputFileSystem, ResolverFactory } from 'enhanced-resolve'
|
|
4
|
+
import { expiringMap } from './expiring-map'
|
|
5
|
+
|
|
6
|
+
const fileSystem = new CachedInputFileSystem(fs as any, 30_000)
|
|
7
|
+
|
|
8
|
+
const esmResolver = ResolverFactory.createResolver({
|
|
9
|
+
fileSystem,
|
|
10
|
+
useSyncFileSystemCalls: true,
|
|
11
|
+
extensions: ['.mjs', '.js'],
|
|
12
|
+
mainFields: ['module'],
|
|
13
|
+
conditionNames: ['node', 'import'],
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const cjsResolver = ResolverFactory.createResolver({
|
|
17
|
+
fileSystem,
|
|
18
|
+
useSyncFileSystemCalls: true,
|
|
19
|
+
extensions: ['.js', '.cjs'],
|
|
20
|
+
mainFields: ['main'],
|
|
21
|
+
conditionNames: ['node', 'require'],
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const cssResolver = ResolverFactory.createResolver({
|
|
25
|
+
fileSystem,
|
|
26
|
+
useSyncFileSystemCalls: true,
|
|
27
|
+
extensions: ['.css'],
|
|
28
|
+
mainFields: ['style'],
|
|
29
|
+
conditionNames: ['style'],
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// This is a long-lived cache for resolved modules whether they exist or not
|
|
33
|
+
// Because we're compatible with a large number of plugins, we need to check
|
|
34
|
+
// for the existence of a module before attempting to import it. This cache
|
|
35
|
+
// is used to mitigate the cost of that check because Node.js does not cache
|
|
36
|
+
// failed module resolutions making repeated checks very expensive.
|
|
37
|
+
const resolveCache = expiringMap<string, string | null>(30_000)
|
|
38
|
+
|
|
39
|
+
export function maybeResolve(name: string) {
|
|
40
|
+
let modpath = resolveCache.get(name)
|
|
41
|
+
|
|
42
|
+
if (modpath === undefined) {
|
|
43
|
+
try {
|
|
44
|
+
modpath = resolveJsFrom(fileURLToPath(import.meta.url), name)
|
|
45
|
+
resolveCache.set(name, modpath)
|
|
46
|
+
} catch (err) {
|
|
47
|
+
resolveCache.set(name, null)
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return modpath
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function loadIfExists<T>(name: string): Promise<T | null> {
|
|
56
|
+
let modpath = maybeResolve(name)
|
|
57
|
+
|
|
58
|
+
if (modpath) {
|
|
59
|
+
let mod = await import(name)
|
|
60
|
+
return mod.default ?? mod
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveJsFrom(base: string, id: string): string {
|
|
67
|
+
try {
|
|
68
|
+
return esmResolver.resolveSync({}, base, id) || id
|
|
69
|
+
} catch (err) {
|
|
70
|
+
return cjsResolver.resolveSync({}, base, id) || id
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function resolveCssFrom(base: string, id: string) {
|
|
75
|
+
return cssResolver.resolveSync({}, base, id) || id
|
|
76
|
+
}
|
package/src/sorter.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { getTailwindConfig } from './config.js'
|
|
2
|
+
import { sortClasses, sortClassList } from './sorting.js'
|
|
3
|
+
|
|
4
|
+
import type { SortTailwindClassesOptions, SortEnv, ContextContainer } from './types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A reusable Tailwind CSS class sorter that holds a context for efficient sorting.
|
|
8
|
+
* Use this when you need to sort classes multiple times with the same configuration.
|
|
9
|
+
*/
|
|
10
|
+
export class TailwindClassSorter {
|
|
11
|
+
private env: SortEnv
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a new TailwindClassSorter with a pre-loaded context.
|
|
15
|
+
*
|
|
16
|
+
* @param contextContainer - Pre-loaded Tailwind context and generateRules function
|
|
17
|
+
* @param options - Configuration options (merged with context)
|
|
18
|
+
*/
|
|
19
|
+
constructor(contextContainer: ContextContainer, options: SortTailwindClassesOptions = {}) {
|
|
20
|
+
this.env = {
|
|
21
|
+
context: contextContainer.context,
|
|
22
|
+
generateRules: contextContainer.generateRules,
|
|
23
|
+
options
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a TailwindClassSorter by loading a Tailwind config file.
|
|
29
|
+
*
|
|
30
|
+
* @param options - Configuration options including config file path
|
|
31
|
+
* @returns Promise resolving to a new TailwindClassSorter instance
|
|
32
|
+
*/
|
|
33
|
+
static async fromConfig(options: SortTailwindClassesOptions = {}): Promise<TailwindClassSorter> {
|
|
34
|
+
const contextContainer = await getTailwindConfig(options)
|
|
35
|
+
return new TailwindClassSorter(contextContainer, options)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sort Tailwind CSS classes synchronously.
|
|
40
|
+
*
|
|
41
|
+
* @param classStr - String of space-separated CSS classes
|
|
42
|
+
* @param overrideOptions - Options to override the instance options for this sort
|
|
43
|
+
* @returns Sorted class string
|
|
44
|
+
*/
|
|
45
|
+
sortClasses(classStr: string, overrideOptions?: Partial<SortTailwindClassesOptions>): string {
|
|
46
|
+
if (!classStr || typeof classStr !== 'string') {
|
|
47
|
+
return classStr
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const env = overrideOptions ? {
|
|
51
|
+
...this.env,
|
|
52
|
+
options: { ...this.env.options, ...overrideOptions }
|
|
53
|
+
} : this.env
|
|
54
|
+
|
|
55
|
+
return sortClasses(classStr, { env })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Sort a list of Tailwind CSS classes synchronously.
|
|
60
|
+
*
|
|
61
|
+
* @param classList - Array of CSS classes
|
|
62
|
+
* @param overrideOptions - Options to override the instance options for this sort
|
|
63
|
+
* @returns Object with sorted classList and removed indices
|
|
64
|
+
*/
|
|
65
|
+
sortClassList(
|
|
66
|
+
classList: string[],
|
|
67
|
+
overrideOptions?: Partial<SortTailwindClassesOptions>
|
|
68
|
+
): { classList: string[], removedIndices: Set<number> } {
|
|
69
|
+
if (!Array.isArray(classList)) {
|
|
70
|
+
return { classList, removedIndices: new Set() }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const env = overrideOptions ? {
|
|
74
|
+
...this.env,
|
|
75
|
+
options: { ...this.env.options, ...overrideOptions }
|
|
76
|
+
} : this.env
|
|
77
|
+
|
|
78
|
+
const effectiveOptions = env.options
|
|
79
|
+
const removeDuplicates = overrideOptions?.tailwindPreserveDuplicates !== undefined
|
|
80
|
+
? !overrideOptions.tailwindPreserveDuplicates
|
|
81
|
+
: !effectiveOptions.tailwindPreserveDuplicates
|
|
82
|
+
|
|
83
|
+
return sortClassList(classList, { env, removeDuplicates })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get the underlying context container.
|
|
88
|
+
*
|
|
89
|
+
* @returns The context container used by this sorter
|
|
90
|
+
*/
|
|
91
|
+
getContext(): ContextContainer {
|
|
92
|
+
return {
|
|
93
|
+
context: this.env.context,
|
|
94
|
+
generateRules: this.env.generateRules
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get the current options.
|
|
100
|
+
*
|
|
101
|
+
* @returns The options used by this sorter
|
|
102
|
+
*/
|
|
103
|
+
getOptions(): SortTailwindClassesOptions {
|
|
104
|
+
return { ...this.env.options }
|
|
105
|
+
}
|
|
106
|
+
}
|
package/src/sorting.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { TailwindContext, SortEnv } from './types'
|
|
2
|
+
|
|
3
|
+
export function bigSign(bigIntValue: bigint) {
|
|
4
|
+
return Number(bigIntValue > 0n) - Number(bigIntValue < 0n)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function prefixCandidate(
|
|
8
|
+
context: TailwindContext,
|
|
9
|
+
selector: string,
|
|
10
|
+
): string {
|
|
11
|
+
let prefix = context.tailwindConfig.prefix
|
|
12
|
+
return typeof prefix === 'function' ? prefix(selector) : prefix + selector
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Polyfill for older Tailwind CSS versions
|
|
16
|
+
function getClassOrderPolyfill(
|
|
17
|
+
classes: string[],
|
|
18
|
+
{ env }: { env: SortEnv },
|
|
19
|
+
): [string, bigint | null][] {
|
|
20
|
+
// A list of utilities that are used by certain Tailwind CSS utilities but
|
|
21
|
+
// that don't exist on their own. This will result in them "not existing" and
|
|
22
|
+
// sorting could be weird since you still require them in order to make the
|
|
23
|
+
// host utitlies work properly. (Thanks Biology)
|
|
24
|
+
let parasiteUtilities = new Set([
|
|
25
|
+
prefixCandidate(env.context, 'group'),
|
|
26
|
+
prefixCandidate(env.context, 'peer'),
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
let classNamesWithOrder: [string, bigint | null][] = []
|
|
30
|
+
|
|
31
|
+
for (let className of classes) {
|
|
32
|
+
let order: bigint | null =
|
|
33
|
+
env
|
|
34
|
+
.generateRules(new Set([className]), env.context)
|
|
35
|
+
.sort(([a], [z]) => bigSign(z - a))[0]?.[0] ?? null
|
|
36
|
+
|
|
37
|
+
if (order === null && parasiteUtilities.has(className)) {
|
|
38
|
+
// This will make sure that it is at the very beginning of the
|
|
39
|
+
// `components` layer which technically means 'before any
|
|
40
|
+
// components'.
|
|
41
|
+
order = env.context.layerOrder.components
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
classNamesWithOrder.push([className, order])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return classNamesWithOrder
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function reorderClasses(classList: string[], { env }: { env: SortEnv }) {
|
|
51
|
+
let orderedClasses = env.context.getClassOrder
|
|
52
|
+
? env.context.getClassOrder(classList)
|
|
53
|
+
: getClassOrderPolyfill(classList, { env })
|
|
54
|
+
|
|
55
|
+
return orderedClasses.sort(([nameA, a], [nameZ, z]) => {
|
|
56
|
+
// Move `...` to the end of the list
|
|
57
|
+
if (nameA === '...' || nameA === '…') return 1
|
|
58
|
+
if (nameZ === '...' || nameZ === '…') return -1
|
|
59
|
+
|
|
60
|
+
if (a === z) return 0
|
|
61
|
+
if (a === null) return -1
|
|
62
|
+
if (z === null) return 1
|
|
63
|
+
return bigSign(a - z)
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function sortClasses(
|
|
68
|
+
classStr: string,
|
|
69
|
+
{
|
|
70
|
+
env,
|
|
71
|
+
ignoreFirst = false,
|
|
72
|
+
ignoreLast = false,
|
|
73
|
+
removeDuplicates = true,
|
|
74
|
+
collapseWhitespace = { start: true, end: true },
|
|
75
|
+
}: {
|
|
76
|
+
env: SortEnv
|
|
77
|
+
ignoreFirst?: boolean
|
|
78
|
+
ignoreLast?: boolean
|
|
79
|
+
removeDuplicates?: boolean
|
|
80
|
+
collapseWhitespace?: false | { start: boolean; end: boolean }
|
|
81
|
+
},
|
|
82
|
+
): string {
|
|
83
|
+
if (typeof classStr !== 'string' || classStr === '') {
|
|
84
|
+
return classStr
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Ignore class attributes containing `{{`, to match Prettier behaviour:
|
|
88
|
+
// https://github.com/prettier/prettier/blob/8a88cdce6d4605f206305ebb9204a0cabf96a070/src/language-html/embed/class-names.js#L9
|
|
89
|
+
if (classStr.includes('{{')) {
|
|
90
|
+
return classStr
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (env.options.tailwindPreserveWhitespace) {
|
|
94
|
+
collapseWhitespace = false
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// This class list is purely whitespace
|
|
98
|
+
// Collapse it to a single space if the option is enabled
|
|
99
|
+
if (/^[\t\r\f\n ]+$/.test(classStr) && collapseWhitespace) {
|
|
100
|
+
return ' '
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let result = ''
|
|
104
|
+
let parts = classStr.split(/([\t\r\f\n ]+)/)
|
|
105
|
+
let classes = parts.filter((_, i) => i % 2 === 0)
|
|
106
|
+
let whitespace = parts.filter((_, i) => i % 2 !== 0)
|
|
107
|
+
|
|
108
|
+
if (classes[classes.length - 1] === '') {
|
|
109
|
+
classes.pop()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (collapseWhitespace) {
|
|
113
|
+
whitespace = whitespace.map(() => ' ')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let prefix = ''
|
|
117
|
+
if (ignoreFirst) {
|
|
118
|
+
prefix = `${classes.shift() ?? ''}${whitespace.shift() ?? ''}`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let suffix = ''
|
|
122
|
+
if (ignoreLast) {
|
|
123
|
+
suffix = `${whitespace.pop() ?? ''}${classes.pop() ?? ''}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let { classList, removedIndices } = sortClassList(classes, {
|
|
127
|
+
env,
|
|
128
|
+
removeDuplicates,
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// Remove whitespace that appeared before a removed classes
|
|
132
|
+
whitespace = whitespace.filter((_, index) => !removedIndices.has(index + 1))
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < classList.length; i++) {
|
|
135
|
+
result += `${classList[i]}${whitespace[i] ?? ''}`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (collapseWhitespace) {
|
|
139
|
+
prefix = prefix.replace(/\s+$/g, ' ')
|
|
140
|
+
suffix = suffix.replace(/^\s+/g, ' ')
|
|
141
|
+
|
|
142
|
+
result = result
|
|
143
|
+
.replace(/^\s+/, collapseWhitespace.start ? '' : ' ')
|
|
144
|
+
.replace(/\s+$/, collapseWhitespace.end ? '' : ' ')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return prefix + result + suffix
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function sortClassList(
|
|
151
|
+
classList: string[],
|
|
152
|
+
{
|
|
153
|
+
env,
|
|
154
|
+
removeDuplicates,
|
|
155
|
+
}: {
|
|
156
|
+
env: SortEnv
|
|
157
|
+
removeDuplicates: boolean
|
|
158
|
+
},
|
|
159
|
+
) {
|
|
160
|
+
// Re-order classes based on the Tailwind CSS configuration
|
|
161
|
+
let orderedClasses = reorderClasses(classList, { env })
|
|
162
|
+
|
|
163
|
+
// Remove duplicate Tailwind classes
|
|
164
|
+
if (env.options.tailwindPreserveDuplicates) {
|
|
165
|
+
removeDuplicates = false
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let removedIndices = new Set<number>()
|
|
169
|
+
|
|
170
|
+
if (removeDuplicates) {
|
|
171
|
+
let seenClasses = new Set<string>()
|
|
172
|
+
|
|
173
|
+
orderedClasses = orderedClasses.filter(([cls, order], index) => {
|
|
174
|
+
if (seenClasses.has(cls)) {
|
|
175
|
+
removedIndices.add(index)
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Only consider known classes when removing duplicates
|
|
180
|
+
if (order !== null) {
|
|
181
|
+
seenClasses.add(cls)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return true
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
classList: orderedClasses.map(([className]) => className),
|
|
190
|
+
removedIndices,
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export interface SortTailwindClassesOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Path to the Tailwind config file.
|
|
4
|
+
*/
|
|
5
|
+
tailwindConfig?: string
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Path to the CSS stylesheet used by Tailwind CSS (v4+)
|
|
9
|
+
*/
|
|
10
|
+
tailwindStylesheet?: string
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Preserve whitespace around Tailwind classes when sorting.
|
|
14
|
+
*/
|
|
15
|
+
tailwindPreserveWhitespace?: boolean
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Preserve duplicate classes inside a class list when sorting.
|
|
19
|
+
*/
|
|
20
|
+
tailwindPreserveDuplicates?: boolean
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Base directory for resolving config files (defaults to process.cwd())
|
|
24
|
+
*/
|
|
25
|
+
baseDir?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TailwindContext {
|
|
29
|
+
tailwindConfig: {
|
|
30
|
+
prefix: string | ((selector: string) => string)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getClassOrder?: (classList: string[]) => [string, bigint | null][]
|
|
34
|
+
|
|
35
|
+
layerOrder: {
|
|
36
|
+
components: bigint
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SortEnv {
|
|
41
|
+
context: TailwindContext
|
|
42
|
+
generateRules: (
|
|
43
|
+
classes: Iterable<string>,
|
|
44
|
+
context: TailwindContext,
|
|
45
|
+
) => [bigint][]
|
|
46
|
+
options: SortTailwindClassesOptions
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ContextContainer {
|
|
50
|
+
context: any
|
|
51
|
+
generateRules: (
|
|
52
|
+
classes: Iterable<string>,
|
|
53
|
+
context: TailwindContext,
|
|
54
|
+
) => [bigint][]
|
|
55
|
+
}
|