@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.
@@ -0,0 +1,2 @@
1
+ import type { ContextContainer, SortTailwindClassesOptions } from './types';
2
+ export declare function getTailwindConfig(options?: SortTailwindClassesOptions): Promise<ContextContainer>;
@@ -0,0 +1,6 @@
1
+ interface ExpiringMap<K, V> {
2
+ get(key: K): V | undefined;
3
+ set(key: K, value: V): void;
4
+ }
5
+ export declare function expiringMap<K, V>(duration: number): ExpiringMap<K, V>;
6
+ export {};
@@ -0,0 +1,27 @@
1
+ import type { SortTailwindClassesOptions } from './types.js';
2
+ export type { SortTailwindClassesOptions, ContextContainer } from './types.js';
3
+ export { TailwindClassSorter } from './sorter.js';
4
+ /**
5
+ * Sort Tailwind CSS classes according to the recommended class order.
6
+ *
7
+ * @param classStr - String of space-separated CSS classes
8
+ * @param options - Configuration options
9
+ * @returns Sorted class string
10
+ */
11
+ export declare function sortTailwindClasses(classStr: string, options?: SortTailwindClassesOptions): Promise<string>;
12
+ /**
13
+ * Sort a list of Tailwind CSS classes.
14
+ *
15
+ * @param classList - Array of CSS classes
16
+ * @param options - Configuration options
17
+ * @returns Object with sorted classList and removed indices
18
+ */
19
+ export declare function sortTailwindClassList(classList: string[], options?: SortTailwindClassesOptions): Promise<{
20
+ classList: string[];
21
+ removedIndices: Set<number>;
22
+ } | {
23
+ classList: never;
24
+ removedIndices: Set<unknown>;
25
+ }>;
26
+ export { sortClasses, sortClassList } from './sorting.js';
27
+ export { getTailwindConfig } from './config.js';
@@ -0,0 +1,4 @@
1
+ export declare function maybeResolve(name: string): string | null;
2
+ export declare function loadIfExists<T>(name: string): Promise<T | null>;
3
+ export declare function resolveJsFrom(base: string, id: string): string;
4
+ export declare function resolveCssFrom(base: string, id: string): string;
@@ -0,0 +1,53 @@
1
+ import type { SortTailwindClassesOptions, ContextContainer } from './types.js';
2
+ /**
3
+ * A reusable Tailwind CSS class sorter that holds a context for efficient sorting.
4
+ * Use this when you need to sort classes multiple times with the same configuration.
5
+ */
6
+ export declare class TailwindClassSorter {
7
+ private env;
8
+ /**
9
+ * Create a new TailwindClassSorter with a pre-loaded context.
10
+ *
11
+ * @param contextContainer - Pre-loaded Tailwind context and generateRules function
12
+ * @param options - Configuration options (merged with context)
13
+ */
14
+ constructor(contextContainer: ContextContainer, options?: SortTailwindClassesOptions);
15
+ /**
16
+ * Create a TailwindClassSorter by loading a Tailwind config file.
17
+ *
18
+ * @param options - Configuration options including config file path
19
+ * @returns Promise resolving to a new TailwindClassSorter instance
20
+ */
21
+ static fromConfig(options?: SortTailwindClassesOptions): Promise<TailwindClassSorter>;
22
+ /**
23
+ * Sort Tailwind CSS classes synchronously.
24
+ *
25
+ * @param classStr - String of space-separated CSS classes
26
+ * @param overrideOptions - Options to override the instance options for this sort
27
+ * @returns Sorted class string
28
+ */
29
+ sortClasses(classStr: string, overrideOptions?: Partial<SortTailwindClassesOptions>): string;
30
+ /**
31
+ * Sort a list of Tailwind CSS classes synchronously.
32
+ *
33
+ * @param classList - Array of CSS classes
34
+ * @param overrideOptions - Options to override the instance options for this sort
35
+ * @returns Object with sorted classList and removed indices
36
+ */
37
+ sortClassList(classList: string[], overrideOptions?: Partial<SortTailwindClassesOptions>): {
38
+ classList: string[];
39
+ removedIndices: Set<number>;
40
+ };
41
+ /**
42
+ * Get the underlying context container.
43
+ *
44
+ * @returns The context container used by this sorter
45
+ */
46
+ getContext(): ContextContainer;
47
+ /**
48
+ * Get the current options.
49
+ *
50
+ * @returns The options used by this sorter
51
+ */
52
+ getOptions(): SortTailwindClassesOptions;
53
+ }
@@ -0,0 +1,19 @@
1
+ import type { SortEnv } from './types';
2
+ export declare function bigSign(bigIntValue: bigint): number;
3
+ export declare function sortClasses(classStr: string, { env, ignoreFirst, ignoreLast, removeDuplicates, collapseWhitespace, }: {
4
+ env: SortEnv;
5
+ ignoreFirst?: boolean;
6
+ ignoreLast?: boolean;
7
+ removeDuplicates?: boolean;
8
+ collapseWhitespace?: false | {
9
+ start: boolean;
10
+ end: boolean;
11
+ };
12
+ }): string;
13
+ export declare function sortClassList(classList: string[], { env, removeDuplicates, }: {
14
+ env: SortEnv;
15
+ removeDuplicates: boolean;
16
+ }): {
17
+ classList: string[];
18
+ removedIndices: Set<number>;
19
+ };
@@ -0,0 +1,40 @@
1
+ export interface SortTailwindClassesOptions {
2
+ /**
3
+ * Path to the Tailwind config file.
4
+ */
5
+ tailwindConfig?: string;
6
+ /**
7
+ * Path to the CSS stylesheet used by Tailwind CSS (v4+)
8
+ */
9
+ tailwindStylesheet?: string;
10
+ /**
11
+ * Preserve whitespace around Tailwind classes when sorting.
12
+ */
13
+ tailwindPreserveWhitespace?: boolean;
14
+ /**
15
+ * Preserve duplicate classes inside a class list when sorting.
16
+ */
17
+ tailwindPreserveDuplicates?: boolean;
18
+ /**
19
+ * Base directory for resolving config files (defaults to process.cwd())
20
+ */
21
+ baseDir?: string;
22
+ }
23
+ export interface TailwindContext {
24
+ tailwindConfig: {
25
+ prefix: string | ((selector: string) => string);
26
+ };
27
+ getClassOrder?: (classList: string[]) => [string, bigint | null][];
28
+ layerOrder: {
29
+ components: bigint;
30
+ };
31
+ }
32
+ export interface SortEnv {
33
+ context: TailwindContext;
34
+ generateRules: (classes: Iterable<string>, context: TailwindContext) => [bigint][];
35
+ options: SortTailwindClassesOptions;
36
+ }
37
+ export interface ContextContainer {
38
+ context: any;
39
+ generateRules: (classes: Iterable<string>, context: TailwindContext) => [bigint][];
40
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "type": "module",
3
+ "name": "@herb-tools/tailwind-class-sorter",
4
+ "description": "Standalone Tailwind CSS class sorter with Prettier plugin compatibility, extracted from tailwindlabs/prettier-plugin-tailwindcss",
5
+ "version": "0.5.0",
6
+ "license": "MIT",
7
+ "main": "./dist/tailwind-class-sorter.cjs",
8
+ "module": "./dist/tailwind-class-sorter.esm.js",
9
+ "types": "./dist/types/index.d.ts",
10
+ "exports": {
11
+ "./package.json": "./package.json",
12
+ ".": {
13
+ "types": "./dist/types/index.d.ts",
14
+ "import": "./dist/tailwind-class-sorter.esm.js",
15
+ "require": "./dist/tailwind-class-sorter.cjs",
16
+ "default": "./dist/tailwind-class-sorter.esm.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "package.json",
21
+ "README.md",
22
+ "dist/",
23
+ "src/"
24
+ ],
25
+ "bugs": "https://github.com/marcoroth/herb/issues/new?title=Package%20%60@herb-tools/tailwind-class-sorter%60:%20",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/marcoroth/herb.git",
29
+ "directory": "javascript/packages/tailwind-class-sorter"
30
+ },
31
+ "scripts": {
32
+ "build": "yarn clean && rollup -c",
33
+ "dev": "rollup -c -w",
34
+ "clean": "rimraf dist",
35
+ "test": "vitest run",
36
+ "test:watch": "vitest --watch",
37
+ "prepublishOnly": "yarn clean && yarn build && yarn test"
38
+ },
39
+ "dependencies": {
40
+ "clear-module": "^4.1.2",
41
+ "escalade": "^3.2.0",
42
+ "jiti": "^2.5.1",
43
+ "postcss": "^8.5.6",
44
+ "postcss-import": "^16.1.1"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^24.1.0",
48
+ "dedent": "^1.6.0",
49
+ "esbuild": "^0.25.8",
50
+ "rimraf": "^6.0.1",
51
+ "tailwindcss": "^3.4.17",
52
+ "tsup": "^8.5.0",
53
+ "vitest": "^3.2.4"
54
+ },
55
+ "peerDependencies": {
56
+ "tailwindcss": "^3.0 || ^4.0"
57
+ },
58
+ "engines": {
59
+ "node": ">=14.21.3"
60
+ }
61
+ }
package/src/config.ts ADDED
@@ -0,0 +1,344 @@
1
+ // @ts-check
2
+ import * as fs from 'fs/promises'
3
+ import * as path from 'path'
4
+ import { pathToFileURL } from 'url'
5
+ import clearModule from 'clear-module'
6
+ import escalade from 'escalade/sync'
7
+ import { createJiti, type Jiti } from 'jiti'
8
+ import postcss from 'postcss'
9
+ // @ts-ignore
10
+ import postcssImport from 'postcss-import'
11
+ // @ts-ignore
12
+ import { generateRules as generateRulesFallback } from 'tailwindcss/lib/lib/generateRules'
13
+ // @ts-ignore
14
+ import { createContext as createContextFallback } from 'tailwindcss/lib/lib/setupContextUtils'
15
+ import loadConfigFallback from 'tailwindcss/loadConfig'
16
+ import resolveConfigFallback from 'tailwindcss/resolveConfig'
17
+ import type { RequiredConfig } from 'tailwindcss/types/config.js'
18
+ import { expiringMap } from './expiring-map.js'
19
+ import { resolveCssFrom, resolveJsFrom } from './resolve'
20
+ import type { ContextContainer, SortTailwindClassesOptions } from './types'
21
+
22
+ let sourceToPathMap = new Map<string, string | null>()
23
+ let sourceToEntryMap = new Map<string, string | null>()
24
+ let pathToContextMap = expiringMap<string | null, ContextContainer>(10_000)
25
+
26
+ export async function getTailwindConfig(
27
+ options: SortTailwindClassesOptions = {},
28
+ ): Promise<ContextContainer> {
29
+ let pkgName = 'tailwindcss'
30
+
31
+ let key = [
32
+ options.baseDir ?? process.cwd(),
33
+ options.tailwindStylesheet ?? '',
34
+ options.tailwindConfig ?? '',
35
+ pkgName,
36
+ ].join(':')
37
+
38
+ let baseDir = getBaseDir(options)
39
+
40
+ // Map the source file to it's associated Tailwind config file
41
+ let configPath = sourceToPathMap.get(key)
42
+ if (configPath === undefined) {
43
+ configPath = getConfigPath(options, baseDir)
44
+ sourceToPathMap.set(key, configPath)
45
+ }
46
+
47
+ let entryPoint = sourceToEntryMap.get(key)
48
+ if (entryPoint === undefined) {
49
+ entryPoint = getEntryPoint(options, baseDir)
50
+ sourceToEntryMap.set(key, entryPoint)
51
+ }
52
+
53
+ // Now see if we've loaded the Tailwind config file before (and it's still valid)
54
+ let contextKey = `${pkgName}:${configPath}:${entryPoint}`
55
+ let existing = pathToContextMap.get(contextKey)
56
+ if (existing) {
57
+ return existing
58
+ }
59
+
60
+ // By this point we know we need to load the Tailwind config file
61
+ let result = await loadTailwindConfig(
62
+ baseDir,
63
+ pkgName,
64
+ configPath,
65
+ entryPoint,
66
+ )
67
+
68
+ pathToContextMap.set(contextKey, result)
69
+
70
+ return result
71
+ }
72
+
73
+ function getBaseDir(options: SortTailwindClassesOptions): string {
74
+ if (options.baseDir) {
75
+ return options.baseDir
76
+ }
77
+
78
+ return process.cwd()
79
+ }
80
+
81
+ async function loadTailwindConfig(
82
+ baseDir: string,
83
+ pkgName: string,
84
+ tailwindConfigPath: string | null,
85
+ entryPoint: string | null,
86
+ ): Promise<ContextContainer> {
87
+ let createContext = createContextFallback
88
+ let generateRules = generateRulesFallback
89
+ let resolveConfig = resolveConfigFallback
90
+ let loadConfig = loadConfigFallback
91
+ let tailwindConfig: RequiredConfig = { content: [] }
92
+
93
+ try {
94
+ let pkgFile = resolveJsFrom(baseDir, `${pkgName}/package.json`)
95
+ let pkgDir = path.dirname(pkgFile)
96
+
97
+ try {
98
+ let v4 = await loadV4(baseDir, pkgDir, pkgName, entryPoint)
99
+ if (v4) {
100
+ return v4
101
+ }
102
+ } catch {}
103
+
104
+ resolveConfig = require(path.join(pkgDir, 'resolveConfig'))
105
+ createContext = require(
106
+ path.join(pkgDir, 'lib/lib/setupContextUtils'),
107
+ ).createContext
108
+ generateRules = require(
109
+ path.join(pkgDir, 'lib/lib/generateRules'),
110
+ ).generateRules
111
+
112
+ // Prior to `tailwindcss@3.3.0` this won't exist so we load it last
113
+ loadConfig = require(path.join(pkgDir, 'loadConfig'))
114
+ } catch {}
115
+
116
+ if (tailwindConfigPath) {
117
+ try {
118
+ clearModule(tailwindConfigPath)
119
+ const loadedConfig = loadConfig(tailwindConfigPath)
120
+ tailwindConfig = loadedConfig.default ?? loadedConfig
121
+ } catch (error) {
122
+ console.warn(`Failed to load Tailwind config from ${tailwindConfigPath}:`, error)
123
+ }
124
+ }
125
+
126
+ // suppress "empty content" warning
127
+ tailwindConfig.content = ['no-op']
128
+
129
+ // Create the context
130
+ let context = createContext(resolveConfig(tailwindConfig))
131
+
132
+ return {
133
+ context,
134
+ generateRules,
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Create a loader function that can load plugins and config files relative to
140
+ * the CSS file that uses them. However, we don't want missing files to prevent
141
+ * everything from working so we'll let the error handler decide how to proceed.
142
+ */
143
+ function createLoader<T>({
144
+ legacy,
145
+ jiti,
146
+ filepath,
147
+ onError,
148
+ }: {
149
+ legacy: boolean
150
+ jiti: Jiti
151
+ filepath: string
152
+ onError: (id: string, error: unknown, resourceType: string) => T
153
+ }) {
154
+ let cacheKey = `${+Date.now()}`
155
+
156
+ async function loadFile(id: string, base: string, resourceType: string) {
157
+ try {
158
+ let resolved = resolveJsFrom(base, id)
159
+
160
+ let url = pathToFileURL(resolved)
161
+ url.searchParams.append('t', cacheKey)
162
+
163
+ return await jiti.import(url.href, { default: true })
164
+ } catch (err) {
165
+ return onError(id, err, resourceType)
166
+ }
167
+ }
168
+
169
+ if (legacy) {
170
+ let baseDir = path.dirname(filepath)
171
+ return (id: string) => loadFile(id, baseDir, 'module')
172
+ }
173
+
174
+ return async (id: string, base: string, resourceType: string) => {
175
+ return {
176
+ base,
177
+ module: await loadFile(id, base, resourceType),
178
+ }
179
+ }
180
+ }
181
+
182
+ async function loadV4(
183
+ baseDir: string,
184
+ pkgDir: string,
185
+ pkgName: string,
186
+ entryPoint: string | null,
187
+ ) {
188
+ // Import Tailwind — if this is v4 it'll have APIs we can use directly
189
+ let pkgPath = resolveJsFrom(baseDir, pkgName)
190
+
191
+ let tw = await import(pathToFileURL(pkgPath).toString())
192
+
193
+ // This is not Tailwind v4
194
+ if (!tw.__unstable__loadDesignSystem) {
195
+ return null
196
+ }
197
+
198
+ // If the user doesn't define an entrypoint then we use the default theme
199
+ entryPoint = entryPoint ?? `${pkgDir}/theme.css`
200
+
201
+ // Create a Jiti instance that can be used to load plugins and config files
202
+ let jiti = createJiti(import.meta.url, {
203
+ moduleCache: false,
204
+ fsCache: false,
205
+ })
206
+
207
+ let importBasePath = path.dirname(entryPoint)
208
+
209
+ // Resolve imports in the entrypoint to a flat CSS tree
210
+ let css = await fs.readFile(entryPoint, 'utf-8')
211
+
212
+ // Determine if the v4 API supports resolving `@import`
213
+ let supportsImports = false
214
+ try {
215
+ await tw.__unstable__loadDesignSystem('@import "./empty";', {
216
+ loadStylesheet: () => {
217
+ supportsImports = true
218
+ return {
219
+ base: importBasePath,
220
+ content: '',
221
+ }
222
+ },
223
+ })
224
+ } catch {}
225
+
226
+ if (!supportsImports) {
227
+ let resolveImports = postcss([postcssImport()])
228
+ let result = await resolveImports.process(css, { from: entryPoint })
229
+ css = result.css
230
+ }
231
+
232
+ // Load the design system and set up a compatible context object that is
233
+ // usable by the rest of the plugin
234
+ let design = await tw.__unstable__loadDesignSystem(css, {
235
+ base: importBasePath,
236
+
237
+ // v4.0.0-alpha.25+
238
+ loadModule: createLoader({
239
+ legacy: false,
240
+ jiti,
241
+ filepath: entryPoint,
242
+ onError: (id, err, resourceType) => {
243
+ console.error(`Unable to load ${resourceType}: ${id}`, err)
244
+
245
+ if (resourceType === 'config') {
246
+ return {}
247
+ } else if (resourceType === 'plugin') {
248
+ return () => {}
249
+ }
250
+ },
251
+ }),
252
+
253
+ loadStylesheet: async (id: string, base: string) => {
254
+ let resolved = resolveCssFrom(base, id)
255
+
256
+ return {
257
+ base: path.dirname(resolved),
258
+ content: await fs.readFile(resolved, 'utf-8'),
259
+ }
260
+ },
261
+
262
+ // v4.0.0-alpha.24 and below
263
+ loadPlugin: createLoader({
264
+ legacy: true,
265
+ jiti,
266
+ filepath: entryPoint,
267
+ onError(id, err) {
268
+ console.error(`Unable to load plugin: ${id}`, err)
269
+
270
+ return () => {}
271
+ },
272
+ }),
273
+
274
+ loadConfig: createLoader({
275
+ legacy: true,
276
+ jiti,
277
+ filepath: entryPoint,
278
+ onError(id, err) {
279
+ console.error(`Unable to load config: ${id}`, err)
280
+
281
+ return {}
282
+ },
283
+ }),
284
+ })
285
+
286
+ return {
287
+ context: {
288
+ getClassOrder: (classList: string[]) => design.getClassOrder(classList),
289
+ },
290
+
291
+ // Stubs that are not needed for v4
292
+ generateRules: () => [],
293
+ }
294
+ }
295
+
296
+ function getConfigPath(options: SortTailwindClassesOptions, baseDir: string): string | null {
297
+ if (options.tailwindConfig) {
298
+ if (options.tailwindConfig.endsWith('.css')) {
299
+ return null
300
+ }
301
+
302
+ return path.resolve(baseDir, options.tailwindConfig)
303
+ }
304
+
305
+ let configPath: string | void = undefined
306
+ try {
307
+ configPath = escalade(baseDir, (_dir, names) => {
308
+ if (names.includes('tailwind.config.js')) {
309
+ return 'tailwind.config.js'
310
+ }
311
+ if (names.includes('tailwind.config.cjs')) {
312
+ return 'tailwind.config.cjs'
313
+ }
314
+ if (names.includes('tailwind.config.mjs')) {
315
+ return 'tailwind.config.mjs'
316
+ }
317
+ if (names.includes('tailwind.config.ts')) {
318
+ return 'tailwind.config.ts'
319
+ }
320
+ })
321
+ } catch {}
322
+
323
+ if (configPath) {
324
+ return configPath
325
+ }
326
+
327
+ return null
328
+ }
329
+
330
+ function getEntryPoint(options: SortTailwindClassesOptions, baseDir: string): string | null {
331
+ if (options.tailwindStylesheet) {
332
+ return path.resolve(baseDir, options.tailwindStylesheet)
333
+ }
334
+
335
+ if (options.tailwindConfig && options.tailwindConfig.endsWith('.css')) {
336
+ console.warn(
337
+ 'Use the `tailwindStylesheet` option for v4 projects instead of `tailwindConfig`.',
338
+ )
339
+
340
+ return path.resolve(baseDir, options.tailwindConfig)
341
+ }
342
+
343
+ return null
344
+ }
@@ -0,0 +1,31 @@
1
+ interface ExpiringMap<K, V> {
2
+ get(key: K): V | undefined
3
+ set(key: K, value: V): void
4
+ }
5
+
6
+ export function expiringMap<K, V>(duration: number): ExpiringMap<K, V> {
7
+ let map = new Map<K, { value: V; expiration: Date }>()
8
+
9
+ return {
10
+ get(key: K) {
11
+ let result = map.get(key)
12
+ if (!result) return undefined
13
+ if (result.expiration <= new Date()) {
14
+ map.delete(key)
15
+ return undefined
16
+ }
17
+
18
+ return result.value
19
+ },
20
+
21
+ set(key: K, value: V) {
22
+ let expiration = new Date()
23
+ expiration.setMilliseconds(expiration.getMilliseconds() + duration)
24
+
25
+ map.set(key, {
26
+ value,
27
+ expiration,
28
+ })
29
+ },
30
+ }
31
+ }
package/src/index.ts ADDED
@@ -0,0 +1,64 @@
1
+ import { getTailwindConfig } from './config.js'
2
+ import { sortClasses, sortClassList } from './sorting.js'
3
+ import type { SortTailwindClassesOptions, SortEnv } from './types.js'
4
+
5
+ export type { SortTailwindClassesOptions, ContextContainer } from './types.js'
6
+ export { TailwindClassSorter } from './sorter.js'
7
+
8
+ /**
9
+ * Sort Tailwind CSS classes according to the recommended class order.
10
+ *
11
+ * @param classStr - String of space-separated CSS classes
12
+ * @param options - Configuration options
13
+ * @returns Sorted class string
14
+ */
15
+ export async function sortTailwindClasses(
16
+ classStr: string,
17
+ options: SortTailwindClassesOptions = {}
18
+ ): Promise<string> {
19
+ if (!classStr || typeof classStr !== 'string') {
20
+ return classStr
21
+ }
22
+
23
+ const { context, generateRules } = await getTailwindConfig(options)
24
+
25
+ const env: SortEnv = {
26
+ context,
27
+ generateRules,
28
+ options
29
+ }
30
+
31
+ return sortClasses(classStr, { env })
32
+ }
33
+
34
+ /**
35
+ * Sort a list of Tailwind CSS classes.
36
+ *
37
+ * @param classList - Array of CSS classes
38
+ * @param options - Configuration options
39
+ * @returns Object with sorted classList and removed indices
40
+ */
41
+ export async function sortTailwindClassList(
42
+ classList: string[],
43
+ options: SortTailwindClassesOptions = {}
44
+ ) {
45
+ if (!Array.isArray(classList)) {
46
+ return { classList, removedIndices: new Set() }
47
+ }
48
+
49
+ const { context, generateRules } = await getTailwindConfig(options)
50
+
51
+ const env: SortEnv = {
52
+ context,
53
+ generateRules,
54
+ options
55
+ }
56
+
57
+ return sortClassList(classList, {
58
+ env,
59
+ removeDuplicates: !options.tailwindPreserveDuplicates
60
+ })
61
+ }
62
+
63
+ export { sortClasses, sortClassList } from './sorting.js'
64
+ export { getTailwindConfig } from './config.js'