@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/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
+ }