From b6d7bc59856fe43effcf2be32aa10653c22fdfec Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Dec 2025 14:30:57 -0500 Subject: [PATCH 01/15] Combine `TransformerContext` and `TransformerEnv` --- src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 791b8fe..b06e325 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,6 @@ import type { StringChange, TransformerEnv } from './types' import { spliceChangesIntoString, visit, type Path } from './utils.js' const ESCAPE_SEQUENCE_PATTERN = /\\(['"\\nrtbfv0-7xuU])/g - function tryParseAngularAttribute(value: string, env: TransformerEnv) { try { return prettierParserAngular.parsers.__ng_directive.parse(value, env.options) From 83dbd81c2703101c4905539a850388fb42556673 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 17 Dec 2025 16:25:44 -0500 Subject: [PATCH 02/15] Work on a public API for sorting classes --- src/index.ts | 8 +++ src/sorter.ts | 137 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 src/sorter.ts diff --git a/src/index.ts b/src/index.ts index b06e325..54213e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1298,21 +1298,29 @@ export interface PluginOptions { /** * List of custom function and tag names that contain classes. + * + * Default: [] */ tailwindFunctions?: string[] /** * List of custom attributes that contain classes. + * + * Default: [] */ tailwindAttributes?: string[] /** * Preserve whitespace around Tailwind classes when sorting. + * + * Default: false */ tailwindPreserveWhitespace?: boolean /** * Preserve duplicate classes inside a class list when sorting. + * + * Default: false */ tailwindPreserveDuplicates?: boolean } diff --git a/src/sorter.ts b/src/sorter.ts new file mode 100644 index 0000000..65a3646 --- /dev/null +++ b/src/sorter.ts @@ -0,0 +1,137 @@ +import { getTailwindConfig } from './config.js' +import { sortClasses, sortClassList } from './sorting.js' +import type { TransformerEnv } from './types.js' + +export interface SorterOptions { + /** + * The path to the file being formatted + * + * Used when loading Tailwind CSS and locating config files + */ + filepath?: string + + /** + * Path to the Tailwind config file. + */ + config?: string + + /** + * Path to the CSS stylesheet used by Tailwind CSS (v4+) + */ + stylesheet?: string + + /** + * List of custom function and tag names that contain classes. + * + * Default: [] + */ + functions?: string[] + + /** + * List of custom attributes that contain classes. + * + * Default: [] + */ + attributes?: string[] + + /** + * Preserve whitespace around Tailwind classes when sorting. + * + * Default: false + */ + preserveWhitespace?: boolean + + /** + * Preserve duplicate classes inside a class list when sorting. + * + * Default: false + */ + preserveDuplicates?: boolean +} + +export interface Sorter { + /** + * Sort one or more class attributes. + * + * Each element is the value of an HTML `class` attribute (or similar). i.e. a + * space separated list of class names as a string. + * + * Postconditions: + * - The returned list is the same length and in the same order as `classes`. + * - Unknown classes are kept in their original, relative order but are moved + * to the beginning of the list. + * - The special "..." token is sorted to the end. + */ + sortClassAttributes(classes: string[]): string[] + + /** + * Sort one or more class lists. + * + * Each element is an array of class names. Passing a space separated class + * list in each element is not supported. + * + * Postconditions: + * - The returned list is the same length and in the same order as `classes`. + * - Unknown classes are kept in their original, relative order but are moved + * to the beginning of the list. + * - The special "..." token is sorted to the end. + * - When removing duplicates they are replaced with `null` + */ + sortClassLists(classes: string[][]): (string | null)[][] +} + +export async function createSorter(opts: SorterOptions): Promise { + let api = await getTailwindConfig(opts) + + let preserveDuplicates = 'preserveDuplicates' in opts ? (opts.preserveDuplicates ?? false) : false + let preserveWhitespace = 'preserveWhitespace' in opts ? (opts.preserveWhitespace ?? false) : false + + let env: TransformerEnv = { + context: api, + changes: [], + options: { + tailwindPreserveWhitespace: preserveWhitespace, + tailwindPreserveDuplicates: preserveDuplicates, + } as any, + matcher: undefined as any, + } + + return { + sortClassLists(classes) { + let output: (string | null)[][] = [...classes] + + for (let [idx, list] of classes.entries()) { + let result = sortClassList({ + api, + classList: list, + removeDuplicates: !preserveDuplicates, + }) + + let sorted: (string | null)[] = [...result.classList] + for (let idx of result.removedIndices) { + sorted[idx] = null + } + + output[idx] = sorted + } + + return output + }, + + sortClassAttributes(classes) { + let output: string[] = [...classes] + + for (let [idx, list] of classes.entries()) { + output[idx] = sortClasses(list, { + ignoreFirst: false, + ignoreLast: false, + removeDuplicates: !preserveDuplicates, + collapseWhitespace: preserveWhitespace ? false : { start: true, end: true }, + env, + }) + } + + return output + }, + } +} From 5ce85946e523c6fdb344c22fd9bfc9f29f010b2b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 17 Dec 2025 16:49:40 -0500 Subject: [PATCH 03/15] wip --- src/index.ts | 7 +++++++ src/sorter.ts | 47 ++++++++++++++++++++--------------------------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/index.ts b/src/index.ts index 54213e9..2fc7e60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1324,3 +1324,10 @@ export interface PluginOptions { */ tailwindPreserveDuplicates?: boolean } + +/** + * Export the public API for creating a sorter from config options + * + * The plugin itself will eventually go through this API + */ +export * from './sorter.js' diff --git a/src/sorter.ts b/src/sorter.ts index 65a3646..3f40816 100644 --- a/src/sorter.ts +++ b/src/sorter.ts @@ -1,4 +1,6 @@ +import type { ParserOptions } from 'prettier' import { getTailwindConfig } from './config.js' +import type { PluginOptions } from './index.js' import { sortClasses, sortClassList } from './sorting.js' import type { TransformerEnv } from './types.js' @@ -81,26 +83,31 @@ export interface Sorter { } export async function createSorter(opts: SorterOptions): Promise { - let api = await getTailwindConfig(opts) + let preserveDuplicates = opts.preserveDuplicates ?? false + let preserveWhitespace = opts.preserveWhitespace ?? false - let preserveDuplicates = 'preserveDuplicates' in opts ? (opts.preserveDuplicates ?? false) : false - let preserveWhitespace = 'preserveWhitespace' in opts ? (opts.preserveWhitespace ?? false) : false + let options: ParserOptions & PluginOptions = { + filepath: opts.filepath as any, + tailwindConfig: opts.config, + tailwindStylesheet: opts.stylesheet, + tailwindFunctions: opts.functions, + tailwindAttributes: opts.attributes, + tailwindPreserveWhitespace: preserveWhitespace, + tailwindPreserveDuplicates: preserveDuplicates, + } as any + + let api = await getTailwindConfig(options) let env: TransformerEnv = { context: api, changes: [], - options: { - tailwindPreserveWhitespace: preserveWhitespace, - tailwindPreserveDuplicates: preserveDuplicates, - } as any, + options, matcher: undefined as any, } return { sortClassLists(classes) { - let output: (string | null)[][] = [...classes] - - for (let [idx, list] of classes.entries()) { + return classes.map((list) => { let result = sortClassList({ api, classList: list, @@ -112,26 +119,12 @@ export async function createSorter(opts: SorterOptions): Promise { sorted[idx] = null } - output[idx] = sorted - } - - return output + return sorted + }) }, sortClassAttributes(classes) { - let output: string[] = [...classes] - - for (let [idx, list] of classes.entries()) { - output[idx] = sortClasses(list, { - ignoreFirst: false, - ignoreLast: false, - removeDuplicates: !preserveDuplicates, - collapseWhitespace: preserveWhitespace ? false : { start: true, end: true }, - env, - }) - } - - return output + return classes.map((list) => sortClasses(list, { env })) }, } } From 7b1e146a3c8fcffedf20c3a3dcf627c4af83b3c5 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 17 Dec 2025 17:04:03 -0500 Subject: [PATCH 04/15] wip --- src/sorter.ts | 49 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/src/sorter.ts b/src/sorter.ts index 3f40816..e631d3c 100644 --- a/src/sorter.ts +++ b/src/sorter.ts @@ -13,42 +13,76 @@ export interface SorterOptions { filepath?: string /** - * Path to the Tailwind config file. + * Path to the Tailwind config file (v3). + * + * Relative paths are resolved to the prettier config file + * determined by `filepath` (or the current working directory + * if no `filepath` is provided). + * + * e.g. `./tailwind.config.js` + * + * Default: The closest `tailwind.config.{js,mjs,cjs,ts}` file relative to + * `filepath` if a local installation of Tailwind CSS v3 is found. */ config?: string /** * Path to the CSS stylesheet used by Tailwind CSS (v4+) + * + * Relative paths are resolved to the prettier config file + * determined by `filepath` (or the current working directory + * if no `filepath` is provided). + * + * e.g. `./src/app.css` + * + * Default: The default Tailwind CSS v4 stylesheet if a local installation of + * Tailwind CSS v4 is found. */ stylesheet?: string /** - * List of custom function and tag names that contain classes. + * List of custom function and tag names whose arguments should be treated as + * a class list and sorted. + * + * e.g. `["clsx", "cn", "tw"]` * - * Default: [] + * Default: `[]` */ functions?: string[] /** - * List of custom attributes that contain classes. + * List of additional HTML/JSX attributes to sort (beyond `class` and `className`). * - * Default: [] + * e.g. `["myClassProp", ":class"]` + * + * Default: `[]` */ attributes?: string[] /** - * Preserve whitespace around Tailwind classes when sorting. + * Preserve whitespace around classes. * * Default: false */ preserveWhitespace?: boolean /** - * Preserve duplicate classes inside a class list when sorting. + * Preserve duplicate classes. * * Default: false */ preserveDuplicates?: boolean + + /** + * The package name to use when loading Tailwind CSS. + * + * Useful when multiple versions are installed in the same project. + * + * Default: `tailwindcss` + * + * @internal + */ + packageName?: boolean } export interface Sorter { @@ -94,6 +128,7 @@ export async function createSorter(opts: SorterOptions): Promise { tailwindAttributes: opts.attributes, tailwindPreserveWhitespace: preserveWhitespace, tailwindPreserveDuplicates: preserveDuplicates, + tailwindPackageName: opts.packageName, } as any let api = await getTailwindConfig(options) From c44531fe146680b4f8d26d20ef2524552403d20f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Wed, 17 Dec 2025 17:04:19 -0500 Subject: [PATCH 05/15] wip --- src/sorter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sorter.ts b/src/sorter.ts index e631d3c..f7e4fbd 100644 --- a/src/sorter.ts +++ b/src/sorter.ts @@ -82,7 +82,7 @@ export interface SorterOptions { * * @internal */ - packageName?: boolean + packageName?: string } export interface Sorter { From 2518c131d2151baab10d862790e89b5bdf2402b4 Mon Sep 17 00:00:00 2001 From: Dunqing Date: Wed, 4 Feb 2026 19:45:51 +0800 Subject: [PATCH 06/15] continue work and re-organize file structure --- README.md | 19 ++ package.json | 12 ++ src/config.ts | 281 +++--------------------------- src/lib.ts | 404 +++++++++++++++++++++++++++++++++++++++++++ src/sorter.ts | 167 +----------------- tests/sorter.test.ts | 33 ++++ tests/utils.ts | 2 - tsdown.config.ts | 2 +- 8 files changed, 499 insertions(+), 421 deletions(-) create mode 100644 src/lib.ts create mode 100644 tests/sorter.test.ts diff --git a/README.md b/README.md index 9fc0088..07f1723 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,25 @@ Once added, tag your strings with the function and the plugin will sort them: const mySortedClasses = tw`bg-white p-4 dark:bg-black` ``` +## Public API + +If you want to use the Tailwind class sorting logic outside of Prettier, import from the +`lib` entrypoint: + +```js +import { createSorter } from 'prettier-plugin-tailwindcss/lib' + +let sorter = await createSorter({ + base: '/path/to/project', + configPath: './tailwind.config.js', +}) + +let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) +``` + +If you already know the formatter config file path, pass `formatterConfigPath` to infer +`base` automatically and improve warning messages. + ### Using regex patterns Like the `tailwindAttributes` option, the `tailwindFunctions` option also supports regular expressions to match multiple function names. Patterns should be enclosed in forward slashes. Note that JS regex literals are not supported with Prettier. diff --git a/package.json b/package.json index b49d1ba..3763af0 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,18 @@ "files": [ "dist" ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + }, + "./lib": { + "types": "./dist/lib.d.mts", + "import": "./dist/lib.mjs", + "default": "./dist/lib.mjs" + } + }, "repository": { "type": "git", "url": "https://github.com/tailwindlabs/prettier-plugin-tailwindcss" diff --git a/src/config.ts b/src/config.ts index 7ed6b82..c1a5ed1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,31 +1,15 @@ // @ts-check import * as path from 'node:path' -import { pathToFileURL } from 'node:url' -import escalade from 'escalade/sync' import prettier from 'prettier' import type { ParserOptions } from 'prettier' import * as console from './console' import { expiringMap } from './expiring-map.js' -import { resolveJsFrom } from './resolve' +import { getTailwindConfig as getTailwindConfigFromLib } from './lib.js' import type { UnifiedApi } from './types' -import { loadV3 } from './versions/v3' -import { loadV4 } from './versions/v4' /** * Cache a value for all directories from `inputDir` up to `targetDir` (inclusive). * Stops early if an existing cache entry is found. - * - * How it works: - * - * For a file at '/repo/packages/ui/src/Button.tsx' with config at '/repo/package.json' - * - * `cacheForDirs(cache, '/repo/packages/ui/src', '/repo/package.json', '/repo')` - * - * Caches: - * - '/repo/packages/ui/src' -> '/repo/package.json' - * - '/repo/packages/ui' -> '/repo/package.json' - * - '/repo/packages' -> '/repo/package.json' - * - '/repo' -> '/repo/package.json' */ function cacheForDirs( cache: { set(key: string, value: V): void; get(key: string): V | undefined }, @@ -46,115 +30,6 @@ function cacheForDirs( } } -let pathToApiMap = expiringMap>(10_000) - -export async function getTailwindConfig(options: ParserOptions): Promise { - let cwd = process.cwd() - - // Locate the file being processed - // - // We'll resolve auto-detected paths relative to this path. - // - // Examples: - // - Tailwind CSS itself - // - Automatically found v3 configs - let inputDir = options.filepath ? path.dirname(options.filepath) : cwd - - // Locate the prettier config - // - // We'll resolve paths defined in the config relative to this path. - // - // Examples: - // - A project's stylesheet - // - // These lookups can take a bit so we cache them. This is especially important - // for files with lots of embedded languages (e.g. Vue bindings). - let [configDir, configPath] = await resolvePrettierConfigPath(options.filepath, inputDir) - - // Locate Tailwind CSS itself - // - // We resolve this like we're in `inputDir` for better monorepo support as - // Prettier may be configured at the workspace root but Tailwind CSS is - // installed for a workspace package rather than the entire monorepo - let [mod, pkgDir] = await resolveTailwindPath(options, inputDir) - - // Locate project stylesheet relative to the prettier config file - // - // We resolve this relative to the config file because it is *required* - // to work with a project's custom config. Given that, resolving it - // relative to where the path is defined makes the most sense. - let stylesheet = resolveStylesheet(options, configDir, configPath) - - // Locate *explicit* v3 configs relative to the prettier config file - // - // We use this as a signal that we should always use v3 to format files even - // when the local install is v4 — which means we'll use the bundled v3. - let jsConfig = resolveJsConfigPath(options, configDir) - - // Locate the closest v3 config file - // - // Note: - // We only need to do this when a stylesheet has not been provided otherwise - // we'd know for sure this was a v4 project regardless of what local Tailwind - // CSS installation is present. Additionally, if the local version is v4 we - // skip this as we assume that the user intends to use that version. - // - // The config path is resolved in one of two ways: - // - // 1. When automatic, relative to the input file - // - // This ensures monorepos can load the "closest" JS config for a given file - // which is important when a workspace package includes Tailwind CSS *and* - // Prettier is configured globally instead of per-package. - // - // 2. When explicit via `tailwindConfig`, relative to the prettier config - // - // For the same reasons as the v4 stylesheet, it's important that the config - // file be resolved relative to the file it's configured in. - if (!stylesheet && !mod?.__unstable__loadDesignSystem) { - jsConfig = jsConfig ?? findClosestJsConfig(inputDir) - } - - // We've found a JS config either because it was specified by the user - // or because it was automatically located. This means we should use v3. - if (jsConfig) { - if (!stylesheet) { - return pathToApiMap.remember(`${pkgDir}:${jsConfig}`, () => loadV3(pkgDir, jsConfig)) - } - - // In this case the user explicitly gave us a stylesheet and a config. - // Warn them about this and use the bundled v4. - console.error( - 'explicit-stylesheet-and-config-together', - configPath ?? '', - `You have specified a Tailwind CSS stylesheet and a Tailwind CSS config at the same time. Use tailwindStylesheet unless you are using v3. Preferring the stylesheet.`, - ) - } - - if (mod && !mod.__unstable__loadDesignSystem) { - if (!stylesheet) { - return pathToApiMap.remember(`${pkgDir}:${jsConfig}`, () => loadV3(pkgDir, jsConfig)) - } - - // In this case the user explicitly gave us a stylesheet but their local - // installation is not v4. We'll fallback to the bundled v4 in this case. - mod = null - console.error( - 'stylesheet-unsupported', - configPath ?? '', - 'You have specified a Tailwind CSS stylesheet but your installed version of Tailwind CSS does not support this feature.', - ) - } - - // If we've detected a local version of v4 then we should fallback to using - // its included theme as the stylesheet if the user didn't give us one. - if (mod && mod.__unstable__loadDesignSystem && pkgDir) { - stylesheet ??= `${pkgDir}/theme.css` - } - - return pathToApiMap.remember(`${pkgDir}:${stylesheet}`, () => loadV4(mod, stylesheet)) -} - let prettierConfigCache = expiringMap(10_000) async function resolvePrettierConfigPath( @@ -189,147 +64,47 @@ async function resolvePrettierConfigPath( return prettierConfig ? [path.dirname(prettierConfig), prettierConfig] : [process.cwd(), null] } -let resolvedModCache = expiringMap(10_000) - -async function resolveTailwindPath( - options: ParserOptions, - baseDir: string, -): Promise<[any | null, string | null]> { - let pkgName = options.tailwindPackageName ?? 'tailwindcss' - let makeKey = (dir: string) => `${pkgName}:${dir}` - - // Check cache for this directory - let cached = resolvedModCache.get(makeKey(baseDir)) - if (cached !== undefined) { - return cached - } - - let resolve = async () => { - let pkgDir: string | null = null - let mod: any | null = null - - try { - let pkgPath = resolveJsFrom(baseDir, pkgName) - mod = await import(pathToFileURL(pkgPath).toString()) - - let pkgFile = resolveJsFrom(baseDir, `${pkgName}/package.json`) - pkgDir = path.dirname(pkgFile) - } catch {} - - return [mod, pkgDir] as [any | null, string | null] - } - - let result = await resolve() - - // Cache all directories from baseDir up to package location - let [, pkgDir] = result - if (pkgDir) { - cacheForDirs(resolvedModCache, baseDir, result, pkgDir, makeKey) - } else { - resolvedModCache.set(makeKey(baseDir), result) - } - - return result -} - -function resolveJsConfigPath(options: ParserOptions, configDir: string): string | null { - if (!options.tailwindConfig) return null - if (options.tailwindConfig.endsWith('.css')) return null - - return path.resolve(configDir, options.tailwindConfig) -} - -let configPathCache = new Map() - -function findClosestJsConfig(inputDir: string): string | null { - // Check cache for this directory - let cached = configPathCache.get(inputDir) - if (cached !== undefined) { - return cached - } - - // Resolve - let configPath: string | null = null - try { - let foundPath = escalade(inputDir, (_, names) => { - if (names.includes('tailwind.config.js')) return 'tailwind.config.js' - if (names.includes('tailwind.config.cjs')) return 'tailwind.config.cjs' - if (names.includes('tailwind.config.mjs')) return 'tailwind.config.mjs' - if (names.includes('tailwind.config.ts')) return 'tailwind.config.ts' - }) - configPath = foundPath ?? null - } catch {} - - // Cache all directories from inputDir up to config location - if (configPath) { - cacheForDirs(configPathCache, inputDir, configPath, path.dirname(configPath)) - } else { - configPathCache.set(inputDir, null) - } +export async function getTailwindConfig(options: ParserOptions): Promise { + let cwd = process.cwd() + let inputDir = options.filepath ? path.dirname(options.filepath) : cwd - return configPath -} + let [configDir, formatterConfigPath] = await resolvePrettierConfigPath( + options.filepath, + inputDir, + ) -function resolveStylesheet( - options: ParserOptions, - baseDir: string, - configPath: string | null, -): string | null { - if (options.tailwindStylesheet) { - if ( - options.tailwindStylesheet.endsWith('.js') || - options.tailwindStylesheet.endsWith('.mjs') || - options.tailwindStylesheet.endsWith('.cjs') || - options.tailwindStylesheet.endsWith('.ts') || - options.tailwindStylesheet.endsWith('.mts') || - options.tailwindStylesheet.endsWith('.cts') - ) { - console.error( - 'stylesheet-is-js-file', - configPath ?? '', - "Your `tailwindStylesheet` option points to a JS/TS config file. You must point to your project's `.css` file for v4 projects.", - ) - } else if ( - options.tailwindStylesheet.endsWith('.sass') || - options.tailwindStylesheet.endsWith('.scss') || - options.tailwindStylesheet.endsWith('.less') || - options.tailwindStylesheet.endsWith('.styl') - ) { - console.error( - 'stylesheet-is-preprocessor-file', - configPath ?? '', - 'Your `tailwindStylesheet` option points to a preprocessor file. This is unsupported and you may get unexpected results.', - ) - } else if (!options.tailwindStylesheet.endsWith('.css')) { - console.error( - 'stylesheet-is-not-css-file', - configPath ?? '', - 'Your `tailwindStylesheet` option does not point to a CSS file. This is unsupported and you may get unexpected results.', - ) - } + let base = formatterConfigPath ? path.dirname(formatterConfigPath) : configDir - return path.resolve(baseDir, options.tailwindStylesheet) - } + let configPath = + options.tailwindConfig && !options.tailwindConfig.endsWith('.css') + ? options.tailwindConfig + : undefined - if (options.tailwindEntryPoint) { + let stylesheetPath = options.tailwindStylesheet + if (!stylesheetPath && options.tailwindEntryPoint) { console.warn( 'entrypoint-is-deprecated', - configPath ?? '', + formatterConfigPath ?? '', 'Deprecated: Use the `tailwindStylesheet` option for v4 projects instead of `tailwindEntryPoint`.', ) - - return path.resolve(baseDir, options.tailwindEntryPoint) + stylesheetPath = options.tailwindEntryPoint } - if (options.tailwindConfig && options.tailwindConfig.endsWith('.css')) { + if (!stylesheetPath && options.tailwindConfig && options.tailwindConfig.endsWith('.css')) { console.warn( 'config-as-css-is-deprecated', - configPath ?? '', + formatterConfigPath ?? '', 'Deprecated: Use the `tailwindStylesheet` option for v4 projects instead of `tailwindConfig`.', ) - - return path.resolve(baseDir, options.tailwindConfig) + stylesheetPath = options.tailwindConfig } - return null + return getTailwindConfigFromLib({ + base, + formatterConfigPath: formatterConfigPath ?? undefined, + filepath: options.filepath, + configPath, + stylesheetPath, + packageName: options.tailwindPackageName, + }) } diff --git a/src/lib.ts b/src/lib.ts new file mode 100644 index 0000000..435f3e2 --- /dev/null +++ b/src/lib.ts @@ -0,0 +1,404 @@ +// @ts-check +import * as path from 'node:path' +import { pathToFileURL } from 'node:url' +import escalade from 'escalade/sync' +import * as console from './console' +import { expiringMap } from './expiring-map.js' +import { resolveJsFrom } from './resolve' +import { sortClasses, sortClassList } from './sorting.js' +import type { TransformerEnv, UnifiedApi } from './types' +import { loadV3 } from './versions/v3' +import { loadV4 } from './versions/v4' + +export interface SorterOptions { + /** + * The directory used to resolve relative file paths. + * + * When not provided this will be: + * - Inferred from `formatterConfigPath` if given; otherwise + * - The current working directory + */ + base?: string + + /** + * The path to the config file containing formatter settings. + * + * Used for warning context and to infer `base` when not provided. + */ + formatterConfigPath?: string + + /** + * The path to the file being formatted. + * + * When provided, Tailwind CSS is resolved relative to this path; otherwise, + * it is resolved relative to `base`. + */ + filepath?: string + + /** + * Path to the Tailwind CSS config file (v3). + * + * Paths are resolved relative to `base`. + */ + configPath?: string + + /** + * Path to the CSS stylesheet used by Tailwind CSS (v4+). + * + * Paths are resolved relative to `base`. + */ + stylesheetPath?: string + + /** + * Whether or not to preserve whitespace around classes. + * + * Default: false + */ + preserveWhitespace?: boolean + + /** + * Whether or not to preserve duplicate classes. + * + * Default: false + */ + preserveDuplicates?: boolean + + /** + * The package name to use when loading Tailwind CSS. + * + * Useful when multiple versions are installed in the same project. + * + * Default: `tailwindcss` + * + * @internal + */ + packageName?: string +} + +export interface Sorter { + /** + * Sort one or more class attributes. + * + * Each element is the value of an HTML `class` attribute (or similar). i.e. a + * space separated list of class names as a string. + */ + sortClassAttributes(classes: string[]): string[] + + /** + * Sort one or more class lists. + * + * Each element is an array of class names. Passing a space separated class + * list in each element is not supported. + * + * When removing duplicates they are replaced with `null`. + */ + sortClassLists(classes: string[][]): (string | null)[][] +} + +type TailwindConfigOptions = { + base?: string + formatterConfigPath?: string + filepath?: string + configPath?: string + stylesheetPath?: string + packageName?: string +} + +function resolveIfRelative(base: string, filePath?: string) { + if (!filePath) return null + return path.isAbsolute(filePath) ? filePath : path.resolve(base, filePath) +} + +/** + * Cache a value for all directories from `inputDir` up to `targetDir` (inclusive). + * Stops early if an existing cache entry is found. + * + * How it works: + * + * For a file at '/repo/packages/ui/src/Button.tsx' with config at '/repo/package.json' + * + * `cacheForDirs(cache, '/repo/packages/ui/src', '/repo/package.json', '/repo')` + * + * Caches: + * - '/repo/packages/ui/src' -> '/repo/package.json' + * - '/repo/packages/ui' -> '/repo/package.json' + * - '/repo/packages' -> '/repo/package.json' + * - '/repo' -> '/repo/package.json' + */ +function cacheForDirs( + cache: { set(key: string, value: V): void; get(key: string): V | undefined }, + inputDir: string, + value: V, + targetDir: string, + makeKey: (dir: string) => string = (dir) => dir, +): void { + let dir = inputDir + while (dir !== path.dirname(dir) && dir.length >= targetDir.length) { + const key = makeKey(dir) + // Stop caching if we hit an existing entry + if (cache.get(key) !== undefined) break + + cache.set(key, value) + if (dir === targetDir) break + dir = path.dirname(dir) + } +} + +let pathToApiMap = expiringMap>(10_000) + +export async function getTailwindConfig(options: TailwindConfigOptions): Promise { + let base = + options.base ?? + (options.formatterConfigPath ? path.dirname(options.formatterConfigPath) : process.cwd()) + let inputDir = options.filepath ? path.dirname(options.filepath) : base + let formatterConfigPath = options.formatterConfigPath ?? '' + + let configPath = resolveIfRelative(base, options.configPath) + let stylesheetPath = resolveIfRelative(base, options.stylesheetPath) + + // Locate Tailwind CSS itself + // + // We resolve this like we're in `inputDir` for better monorepo support as + // Prettier may be configured at the workspace root but Tailwind CSS is + // installed for a workspace package rather than the entire monorepo + let [mod, pkgDir] = await resolveTailwindPath({ packageName: options.packageName }, inputDir) + + // Locate project stylesheet relative to the formatter config file + // + // We resolve this relative to the config file because it is *required* + // to work with a project's custom config. Given that, resolving it + // relative to where the path is defined makes the most sense. + let stylesheet = resolveStylesheet(stylesheetPath, formatterConfigPath) + + // Locate *explicit* v3 configs relative to the formatter config file + // + // We use this as a signal that we should always use v3 to format files even + // when the local install is v4 — which means we'll use the bundled v3. + let jsConfig = resolveJsConfigPath(configPath) + + // Locate the closest v3 config file + // + // Note: + // We only need to do this when a stylesheet has not been provided otherwise + // we'd know for sure this was a v4 project regardless of what local Tailwind + // CSS installation is present. Additionally, if the local version is v4 we + // skip this as we assume that the user intends to use that version. + // + // The config path is resolved in one of two ways: + // + // 1. When automatic, relative to the input file + // + // This ensures monorepos can load the "closest" JS config for a given file + // which is important when a workspace package includes Tailwind CSS *and* + // Prettier is configured globally instead of per-package. + // + // 2. When explicit via `configPath`, relative to `base` + if (!stylesheet && !mod?.__unstable__loadDesignSystem) { + jsConfig = jsConfig ?? findClosestJsConfig(inputDir) + } + + // We've found a JS config either because it was specified by the user + // or because it was automatically located. This means we should use v3. + if (jsConfig) { + if (!stylesheet) { + return pathToApiMap.remember(`${pkgDir}:${jsConfig}`, () => loadV3(pkgDir, jsConfig)) + } + + // In this case the user explicitly gave us a stylesheet and a config. + // Warn them about this and use the bundled v4. + console.error( + 'explicit-stylesheet-and-config-together', + formatterConfigPath, + `You have specified a Tailwind CSS stylesheet and a Tailwind CSS config at the same time. Use stylesheetPath unless you are using v3. Preferring the stylesheet.`, + ) + } + + if (mod && !mod.__unstable__loadDesignSystem) { + if (!stylesheet) { + return pathToApiMap.remember(`${pkgDir}:${jsConfig}`, () => loadV3(pkgDir, jsConfig)) + } + + // In this case the user explicitly gave us a stylesheet but their local + // installation is not v4. We'll fallback to the bundled v4 in this case. + mod = null + console.error( + 'stylesheet-unsupported', + formatterConfigPath, + 'You have specified a Tailwind CSS stylesheet but your installed version of Tailwind CSS does not support this feature.', + ) + } + + // If we've detected a local version of v4 then we should fallback to using + // its included theme as the stylesheet if the user didn't give us one. + if (mod && mod.__unstable__loadDesignSystem && pkgDir) { + stylesheet ??= `${pkgDir}/theme.css` + } + + return pathToApiMap.remember(`${pkgDir}:${stylesheet}`, () => loadV4(mod, stylesheet)) +} + +let resolvedModCache = expiringMap(10_000) + +async function resolveTailwindPath( + options: { packageName?: string }, + baseDir: string, +): Promise<[any | null, string | null]> { + let pkgName = options.packageName ?? 'tailwindcss' + let makeKey = (dir: string) => `${pkgName}:${dir}` + + // Check cache for this directory + let cached = resolvedModCache.get(makeKey(baseDir)) + if (cached !== undefined) { + return cached + } + + let resolve = async () => { + let pkgDir: string | null = null + let mod: any | null = null + + try { + let pkgPath = resolveJsFrom(baseDir, pkgName) + mod = await import(pathToFileURL(pkgPath).toString()) + + let pkgFile = resolveJsFrom(baseDir, `${pkgName}/package.json`) + pkgDir = path.dirname(pkgFile) + } catch {} + + return [mod, pkgDir] as [any | null, string | null] + } + + let result = await resolve() + + // Cache all directories from baseDir up to package location + let [, pkgDir] = result + if (pkgDir) { + cacheForDirs(resolvedModCache, baseDir, result, pkgDir, makeKey) + } else { + resolvedModCache.set(makeKey(baseDir), result) + } + + return result +} + +function resolveJsConfigPath(configPath: string | null): string | null { + if (!configPath) return null + if (configPath.endsWith('.css')) return null + return configPath +} + +let configPathCache = new Map() + +function findClosestJsConfig(inputDir: string): string | null { + // Check cache for this directory + let cached = configPathCache.get(inputDir) + if (cached !== undefined) { + return cached + } + + // Resolve + let configPath: string | null = null + try { + let foundPath = escalade(inputDir, (_, names) => { + if (names.includes('tailwind.config.js')) return 'tailwind.config.js' + if (names.includes('tailwind.config.cjs')) return 'tailwind.config.cjs' + if (names.includes('tailwind.config.mjs')) return 'tailwind.config.mjs' + if (names.includes('tailwind.config.ts')) return 'tailwind.config.ts' + }) + configPath = foundPath ?? null + } catch {} + + // Cache all directories from inputDir up to config location + if (configPath) { + cacheForDirs(configPathCache, inputDir, configPath, path.dirname(configPath)) + } else { + configPathCache.set(inputDir, null) + } + + return configPath +} + +function resolveStylesheet(stylesheetPath: string | null, formatterConfigPath: string): string | null { + if (!stylesheetPath) return null + + if ( + stylesheetPath.endsWith('.js') || + stylesheetPath.endsWith('.mjs') || + stylesheetPath.endsWith('.cjs') || + stylesheetPath.endsWith('.ts') || + stylesheetPath.endsWith('.mts') || + stylesheetPath.endsWith('.cts') + ) { + console.error( + 'stylesheet-is-js-file', + formatterConfigPath, + "Your `stylesheetPath` option points to a JS/TS config file. You must point to your project's `.css` file for v4 projects.", + ) + } else if ( + stylesheetPath.endsWith('.sass') || + stylesheetPath.endsWith('.scss') || + stylesheetPath.endsWith('.less') || + stylesheetPath.endsWith('.styl') + ) { + console.error( + 'stylesheet-is-preprocessor-file', + formatterConfigPath, + 'Your `stylesheetPath` option points to a preprocessor file. This is unsupported and you may get unexpected results.', + ) + } else if (!stylesheetPath.endsWith('.css')) { + console.error( + 'stylesheet-is-not-css-file', + formatterConfigPath, + 'Your `stylesheetPath` option does not point to a CSS file. This is unsupported and you may get unexpected results.', + ) + } + + return stylesheetPath +} + +export async function createSorter(opts: SorterOptions): Promise { + let preserveDuplicates = opts.preserveDuplicates ?? false + let preserveWhitespace = opts.preserveWhitespace ?? false + + let api = await getTailwindConfig({ + base: opts.base, + formatterConfigPath: opts.formatterConfigPath, + filepath: opts.filepath, + configPath: opts.configPath, + stylesheetPath: opts.stylesheetPath, + packageName: opts.packageName, + }) + + let env: TransformerEnv = { + context: api, + changes: [], + options: { + tailwindPreserveWhitespace: preserveWhitespace, + tailwindPreserveDuplicates: preserveDuplicates, + tailwindPackageName: opts.packageName, + } as any, + matcher: undefined as any, + } + + return { + sortClassLists(classes) { + return classes.map((list) => { + let result = sortClassList({ + api, + classList: list, + removeDuplicates: !preserveDuplicates, + }) + + let sorted: (string | null)[] = [...result.classList] + for (let idx of result.removedIndices) { + sorted[idx] = null + } + + return sorted + }) + }, + + sortClassAttributes(classes) { + return classes.map((list) => sortClasses(list, { env })) + }, + } +} diff --git a/src/sorter.ts b/src/sorter.ts index f7e4fbd..dcdf632 100644 --- a/src/sorter.ts +++ b/src/sorter.ts @@ -1,165 +1,2 @@ -import type { ParserOptions } from 'prettier' -import { getTailwindConfig } from './config.js' -import type { PluginOptions } from './index.js' -import { sortClasses, sortClassList } from './sorting.js' -import type { TransformerEnv } from './types.js' - -export interface SorterOptions { - /** - * The path to the file being formatted - * - * Used when loading Tailwind CSS and locating config files - */ - filepath?: string - - /** - * Path to the Tailwind config file (v3). - * - * Relative paths are resolved to the prettier config file - * determined by `filepath` (or the current working directory - * if no `filepath` is provided). - * - * e.g. `./tailwind.config.js` - * - * Default: The closest `tailwind.config.{js,mjs,cjs,ts}` file relative to - * `filepath` if a local installation of Tailwind CSS v3 is found. - */ - config?: string - - /** - * Path to the CSS stylesheet used by Tailwind CSS (v4+) - * - * Relative paths are resolved to the prettier config file - * determined by `filepath` (or the current working directory - * if no `filepath` is provided). - * - * e.g. `./src/app.css` - * - * Default: The default Tailwind CSS v4 stylesheet if a local installation of - * Tailwind CSS v4 is found. - */ - stylesheet?: string - - /** - * List of custom function and tag names whose arguments should be treated as - * a class list and sorted. - * - * e.g. `["clsx", "cn", "tw"]` - * - * Default: `[]` - */ - functions?: string[] - - /** - * List of additional HTML/JSX attributes to sort (beyond `class` and `className`). - * - * e.g. `["myClassProp", ":class"]` - * - * Default: `[]` - */ - attributes?: string[] - - /** - * Preserve whitespace around classes. - * - * Default: false - */ - preserveWhitespace?: boolean - - /** - * Preserve duplicate classes. - * - * Default: false - */ - preserveDuplicates?: boolean - - /** - * The package name to use when loading Tailwind CSS. - * - * Useful when multiple versions are installed in the same project. - * - * Default: `tailwindcss` - * - * @internal - */ - packageName?: string -} - -export interface Sorter { - /** - * Sort one or more class attributes. - * - * Each element is the value of an HTML `class` attribute (or similar). i.e. a - * space separated list of class names as a string. - * - * Postconditions: - * - The returned list is the same length and in the same order as `classes`. - * - Unknown classes are kept in their original, relative order but are moved - * to the beginning of the list. - * - The special "..." token is sorted to the end. - */ - sortClassAttributes(classes: string[]): string[] - - /** - * Sort one or more class lists. - * - * Each element is an array of class names. Passing a space separated class - * list in each element is not supported. - * - * Postconditions: - * - The returned list is the same length and in the same order as `classes`. - * - Unknown classes are kept in their original, relative order but are moved - * to the beginning of the list. - * - The special "..." token is sorted to the end. - * - When removing duplicates they are replaced with `null` - */ - sortClassLists(classes: string[][]): (string | null)[][] -} - -export async function createSorter(opts: SorterOptions): Promise { - let preserveDuplicates = opts.preserveDuplicates ?? false - let preserveWhitespace = opts.preserveWhitespace ?? false - - let options: ParserOptions & PluginOptions = { - filepath: opts.filepath as any, - tailwindConfig: opts.config, - tailwindStylesheet: opts.stylesheet, - tailwindFunctions: opts.functions, - tailwindAttributes: opts.attributes, - tailwindPreserveWhitespace: preserveWhitespace, - tailwindPreserveDuplicates: preserveDuplicates, - tailwindPackageName: opts.packageName, - } as any - - let api = await getTailwindConfig(options) - - let env: TransformerEnv = { - context: api, - changes: [], - options, - matcher: undefined as any, - } - - return { - sortClassLists(classes) { - return classes.map((list) => { - let result = sortClassList({ - api, - classList: list, - removeDuplicates: !preserveDuplicates, - }) - - let sorted: (string | null)[] = [...result.classList] - for (let idx of result.removedIndices) { - sorted[idx] = null - } - - return sorted - }) - }, - - sortClassAttributes(classes) { - return classes.map((list) => sortClasses(list, { env })) - }, - } -} +export { createSorter } from './lib.js' +export type { Sorter, SorterOptions } from './lib.js' diff --git a/tests/sorter.test.ts b/tests/sorter.test.ts new file mode 100644 index 0000000..4da9d01 --- /dev/null +++ b/tests/sorter.test.ts @@ -0,0 +1,33 @@ +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, test } from 'vitest' +import { createSorter } from '../src/lib' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +describe('createSorter', () => { + test('sorts with base + relative configPath', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/basic') + let sorter = await createSorter({ + base: fixtureDir, + filepath: path.join(fixtureDir, 'index.html'), + configPath: './tailwind.config.js', + }) + + let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) + expect(sorted).toBe('bg-red-500 sm:bg-tomato') + }) + + test('infers base from formatterConfigPath', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/basic') + let sorter = await createSorter({ + formatterConfigPath: path.join(fixtureDir, 'prettier.config.js'), + filepath: path.join(fixtureDir, 'index.html'), + configPath: './tailwind.config.js', + }) + + let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) + expect(sorted).toBe('bg-red-500 sm:bg-tomato') + }) +}) diff --git a/tests/utils.ts b/tests/utils.ts index 164b698..bd7340e 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -40,8 +40,6 @@ export function t(strings: TemplateStringsArray, ...values: string[]): TestEntry export let pluginPath = path.resolve(__dirname, '../dist/index.mjs') export async function format(str: string, options: prettier.Options = {}) { - let plugin: prettier.Plugin = (await import('../src/index.ts')) as any - let result = await prettier.format(str, { semi: false, singleQuote: true, diff --git a/tsdown.config.ts b/tsdown.config.ts index 7250cac..28cd4cb 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -93,7 +93,7 @@ function inlineCssImports(): Plugin { } export default defineConfig({ - entry: ['./src/index.ts'], + entry: ['./src/index.ts', './src/lib.ts'], outDir: './dist', format: 'esm', platform: 'node', From 8a011d50fb3377226e0ee7b715af5ae3d59a3ff8 Mon Sep 17 00:00:00 2001 From: Dunqing Date: Wed, 4 Feb 2026 21:38:09 +0800 Subject: [PATCH 07/15] update config --- tsdown.config.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tsdown.config.ts b/tsdown.config.ts index 28cd4cb..3b80c84 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,13 +1,13 @@ import { readFile } from 'node:fs/promises' import * as path from 'node:path' -import type { Plugin } from 'rolldown' -import { defineConfig } from 'tsdown' +import { defineConfig, Rolldown } from 'tsdown' + /** * Patches recast to fix template literal spacing issues. * @see https://github.com/benjamn/recast/issues/611 */ -function patchRecast(): Plugin { +function patchRecast(): Rolldown.Plugin { return { name: 'patch-recast', async load(id) { @@ -34,7 +34,7 @@ function patchRecast(): Plugin { /** * Patches jiti to use require for babel import. */ -function patchJiti(): Plugin { +function patchJiti(): Rolldown.Plugin { return { name: 'patch-jiti', async load(id) { @@ -56,7 +56,7 @@ function patchJiti(): Plugin { /** * Inlines CSS imports as JavaScript strings. */ -function inlineCssImports(): Plugin { +function inlineCssImports(): Rolldown.Plugin { return { name: 'inline-css-imports', async load(id) { @@ -94,7 +94,6 @@ function inlineCssImports(): Plugin { export default defineConfig({ entry: ['./src/index.ts', './src/lib.ts'], - outDir: './dist', format: 'esm', platform: 'node', target: 'node14.21.3', From 996fa68a244e2136629a7b3b7e4240901496c507 Mon Sep 17 00:00:00 2001 From: Dunqing Date: Thu, 5 Feb 2026 09:10:14 +0800 Subject: [PATCH 08/15] Remove `formatterConfigPath` --- README.md | 3 --- src/config.ts | 23 +++++++++++------------ src/lib.ts | 29 ++++++++--------------------- tests/sorter.test.ts | 11 ----------- 4 files changed, 19 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 07f1723..625c0cb 100644 --- a/README.md +++ b/README.md @@ -208,9 +208,6 @@ let sorter = await createSorter({ let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) ``` -If you already know the formatter config file path, pass `formatterConfigPath` to infer -`base` automatically and improve warning messages. - ### Using regex patterns Like the `tailwindAttributes` option, the `tailwindFunctions` option also supports regular expressions to match multiple function names. Patterns should be enclosed in forward slashes. Note that JS regex literals are not supported with Prettier. diff --git a/src/config.ts b/src/config.ts index c1a5ed1..a24c452 100644 --- a/src/config.ts +++ b/src/config.ts @@ -32,14 +32,14 @@ function cacheForDirs( let prettierConfigCache = expiringMap(10_000) -async function resolvePrettierConfigPath( +async function resolvePrettierConfigDir( filePath: string, inputDir: string, -): Promise<[string, string | null]> { +): Promise { // Check cache for this directory let cached = prettierConfigCache.get(inputDir) if (cached !== undefined) { - return cached ? [path.dirname(cached), cached] : [process.cwd(), null] + return cached ?? process.cwd() } const resolve = async () => { @@ -56,24 +56,24 @@ async function resolvePrettierConfigPath( // Cache all directories from inputDir up to config location if (prettierConfig) { - cacheForDirs(prettierConfigCache, inputDir, prettierConfig, path.dirname(prettierConfig)) + let configDir = path.dirname(prettierConfig) + cacheForDirs(prettierConfigCache, inputDir, configDir, configDir) + return configDir } else { prettierConfigCache.set(inputDir, null) + return process.cwd() } - - return prettierConfig ? [path.dirname(prettierConfig), prettierConfig] : [process.cwd(), null] } export async function getTailwindConfig(options: ParserOptions): Promise { let cwd = process.cwd() let inputDir = options.filepath ? path.dirname(options.filepath) : cwd - let [configDir, formatterConfigPath] = await resolvePrettierConfigPath( + let configDir = await resolvePrettierConfigDir( options.filepath, inputDir, ) - let base = formatterConfigPath ? path.dirname(formatterConfigPath) : configDir let configPath = options.tailwindConfig && !options.tailwindConfig.endsWith('.css') @@ -84,7 +84,7 @@ export async function getTailwindConfig(options: ParserOptions): Promise( let pathToApiMap = expiringMap>(10_000) export async function getTailwindConfig(options: TailwindConfigOptions): Promise { - let base = - options.base ?? - (options.formatterConfigPath ? path.dirname(options.formatterConfigPath) : process.cwd()) + let base = options.base ?? process.cwd() let inputDir = options.filepath ? path.dirname(options.filepath) : base - let formatterConfigPath = options.formatterConfigPath ?? '' let configPath = resolveIfRelative(base, options.configPath) let stylesheetPath = resolveIfRelative(base, options.stylesheetPath) @@ -168,7 +156,7 @@ export async function getTailwindConfig(options: TailwindConfigOptions): Promise // We resolve this relative to the config file because it is *required* // to work with a project's custom config. Given that, resolving it // relative to where the path is defined makes the most sense. - let stylesheet = resolveStylesheet(stylesheetPath, formatterConfigPath) + let stylesheet = resolveStylesheet(stylesheetPath, base) // Locate *explicit* v3 configs relative to the formatter config file // @@ -208,7 +196,7 @@ export async function getTailwindConfig(options: TailwindConfigOptions): Promise // Warn them about this and use the bundled v4. console.error( 'explicit-stylesheet-and-config-together', - formatterConfigPath, + base, `You have specified a Tailwind CSS stylesheet and a Tailwind CSS config at the same time. Use stylesheetPath unless you are using v3. Preferring the stylesheet.`, ) } @@ -223,7 +211,7 @@ export async function getTailwindConfig(options: TailwindConfigOptions): Promise mod = null console.error( 'stylesheet-unsupported', - formatterConfigPath, + base, 'You have specified a Tailwind CSS stylesheet but your installed version of Tailwind CSS does not support this feature.', ) } @@ -317,7 +305,7 @@ function findClosestJsConfig(inputDir: string): string | null { return configPath } -function resolveStylesheet(stylesheetPath: string | null, formatterConfigPath: string): string | null { +function resolveStylesheet(stylesheetPath: string | null, base: string): string | null { if (!stylesheetPath) return null if ( @@ -330,7 +318,7 @@ function resolveStylesheet(stylesheetPath: string | null, formatterConfigPath: s ) { console.error( 'stylesheet-is-js-file', - formatterConfigPath, + base, "Your `stylesheetPath` option points to a JS/TS config file. You must point to your project's `.css` file for v4 projects.", ) } else if ( @@ -341,13 +329,13 @@ function resolveStylesheet(stylesheetPath: string | null, formatterConfigPath: s ) { console.error( 'stylesheet-is-preprocessor-file', - formatterConfigPath, + base, 'Your `stylesheetPath` option points to a preprocessor file. This is unsupported and you may get unexpected results.', ) } else if (!stylesheetPath.endsWith('.css')) { console.error( 'stylesheet-is-not-css-file', - formatterConfigPath, + base, 'Your `stylesheetPath` option does not point to a CSS file. This is unsupported and you may get unexpected results.', ) } @@ -361,7 +349,6 @@ export async function createSorter(opts: SorterOptions): Promise { let api = await getTailwindConfig({ base: opts.base, - formatterConfigPath: opts.formatterConfigPath, filepath: opts.filepath, configPath: opts.configPath, stylesheetPath: opts.stylesheetPath, diff --git a/tests/sorter.test.ts b/tests/sorter.test.ts index 4da9d01..17e87be 100644 --- a/tests/sorter.test.ts +++ b/tests/sorter.test.ts @@ -19,15 +19,4 @@ describe('createSorter', () => { expect(sorted).toBe('bg-red-500 sm:bg-tomato') }) - test('infers base from formatterConfigPath', async () => { - let fixtureDir = path.resolve(__dirname, 'fixtures/basic') - let sorter = await createSorter({ - formatterConfigPath: path.join(fixtureDir, 'prettier.config.js'), - filepath: path.join(fixtureDir, 'index.html'), - configPath: './tailwind.config.js', - }) - - let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) - expect(sorted).toBe('bg-red-500 sm:bg-tomato') - }) }) From 3fede5d577df19e0a28a7925436f6b3203351322 Mon Sep 17 00:00:00 2001 From: Dunqing Date: Thu, 5 Feb 2026 09:26:07 +0800 Subject: [PATCH 09/15] Remove duplicate `cacheForDirs` and strip internal types in dts --- src/config.ts | 25 +------------------------ src/lib.ts | 8 +++++++- tsconfig.json | 4 +++- 3 files changed, 11 insertions(+), 26 deletions(-) diff --git a/src/config.ts b/src/config.ts index a24c452..2753c3d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,32 +4,9 @@ import prettier from 'prettier' import type { ParserOptions } from 'prettier' import * as console from './console' import { expiringMap } from './expiring-map.js' -import { getTailwindConfig as getTailwindConfigFromLib } from './lib.js' +import { cacheForDirs, getTailwindConfig as getTailwindConfigFromLib } from './lib.js' import type { UnifiedApi } from './types' -/** - * Cache a value for all directories from `inputDir` up to `targetDir` (inclusive). - * Stops early if an existing cache entry is found. - */ -function cacheForDirs( - cache: { set(key: string, value: V): void; get(key: string): V | undefined }, - inputDir: string, - value: V, - targetDir: string, - makeKey: (dir: string) => string = (dir) => dir, -): void { - let dir = inputDir - while (dir !== path.dirname(dir) && dir.length >= targetDir.length) { - const key = makeKey(dir) - // Stop caching if we hit an existing entry - if (cache.get(key) !== undefined) break - - cache.set(key, value) - if (dir === targetDir) break - dir = path.dirname(dir) - } -} - let prettierConfigCache = expiringMap(10_000) async function resolvePrettierConfigDir( diff --git a/src/lib.ts b/src/lib.ts index 3780bae..ae93062 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -115,8 +115,10 @@ function resolveIfRelative(base: string, filePath?: string) { * - '/repo/packages/ui' -> '/repo/package.json' * - '/repo/packages' -> '/repo/package.json' * - '/repo' -> '/repo/package.json' + * + * @internal */ -function cacheForDirs( +export function cacheForDirs( cache: { set(key: string, value: V): void; get(key: string): V | undefined }, inputDir: string, value: V, @@ -137,6 +139,10 @@ function cacheForDirs( let pathToApiMap = expiringMap>(10_000) +/** + * Get a Tailwind CSS API instance based on the provided options. + * @internal + */ export async function getTailwindConfig(options: TailwindConfigOptions): Promise { let base = options.base ?? process.cwd() let inputDir = options.filepath ? path.dirname(options.filepath) : base diff --git a/tsconfig.json b/tsconfig.json index 5f316a9..63d8b1c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,9 @@ "skipLibCheck": true, "strict": true, "noFallthroughCasesInSwitch": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true , + + "stripInternal": true, }, "include": ["src/*.ts"], "exclude": ["node_modules"] From 843a4dc603c32b1b2ca19c538578743971c54b73 Mon Sep 17 00:00:00 2001 From: Dunqing Date: Thu, 5 Feb 2026 11:22:51 +0800 Subject: [PATCH 10/15] Avoid resolving the Prettier configuration if there is no config or if all paths are absolute path --- src/config.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index 2753c3d..b9c6385 100644 --- a/src/config.ts +++ b/src/config.ts @@ -46,11 +46,18 @@ export async function getTailwindConfig(options: ParserOptions): Promise Date: Thu, 5 Feb 2026 11:53:15 +0800 Subject: [PATCH 11/15] Don't export sorter apis in the main entry --- src/index.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 2fc7e60..54213e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1324,10 +1324,3 @@ export interface PluginOptions { */ tailwindPreserveDuplicates?: boolean } - -/** - * Export the public API for creating a sorter from config options - * - * The plugin itself will eventually go through this API - */ -export * from './sorter.js' From fd0eca1cfb0e9145e8645329a35d4abeeb1a4945 Mon Sep 17 00:00:00 2001 From: Dunqing Date: Thu, 5 Feb 2026 12:18:38 +0800 Subject: [PATCH 12/15] improve --- README.md | 37 ++++++++- src/lib.ts | 16 ++++ src/sorter.ts | 2 - tests/sorter.test.ts | 182 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 225 insertions(+), 12 deletions(-) delete mode 100644 src/sorter.ts diff --git a/README.md b/README.md index 625c0cb..286c16a 100644 --- a/README.md +++ b/README.md @@ -202,12 +202,45 @@ import { createSorter } from 'prettier-plugin-tailwindcss/lib' let sorter = await createSorter({ base: '/path/to/project', - configPath: './tailwind.config.js', + stylesheetPath: './app.css', }) -let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) +// Sort HTML class attributes (space-separated strings) +let sorted = sorter.sortClassAttributes([ + 'sm:bg-tomato bg-red-500', + 'p-4 m-2' +]) +// Returns: ['bg-red-500 sm:bg-tomato', 'm-2 p-4'] + +// Sort class lists (arrays of class names) +let sortedLists = sorter.sortClassLists([ + ['sm:bg-tomato', 'bg-red-500'], + ['p-4', 'm-2'] +]) +// Returns: [['bg-red-500', 'sm:bg-tomato'], ['m-2', 'p-4']] ``` +### API Options + +The `createSorter` function accepts the following options: + +- **`base`** (optional): The directory used to resolve relative file paths. Defaults to the current working directory. +- **`filepath`** (optional): The path to the file being formatted. When provided, Tailwind CSS is resolved relative to this path. +- **`configPath`** (optional): Path to the Tailwind CSS config file (v3). Paths are resolved relative to `base`. +- **`stylesheetPath`** (optional): Path to the CSS stylesheet used by Tailwind CSS (v4+). Paths are resolved relative to `base`. +- **`preserveWhitespace`** (optional): Whether to preserve whitespace around classes. Default: `false`. +- **`preserveDuplicates`** (optional): Whether to preserve duplicate classes. Default: `false`. + +### Sorter Methods + +The sorter object returned by `createSorter` has two methods: + +- **`sortClassAttributes(classes: string[]): string[]`** + Sorts one or more HTML class attributes. Each element should be a space-separated string of class names (like the value of an HTML `class` attribute). + +- **`sortClassLists(classes: string[][]): (string | null)[][]`** + Sorts one or more class lists. Each element should be an array of individual class names. When removing duplicates (default behavior), duplicate classes are replaced with `null` in the output. + ### Using regex patterns Like the `tailwindAttributes` option, the `tailwindFunctions` option also supports regular expressions to match multiple function names. Patterns should be enclosed in forward slashes. Note that JS regex literals are not supported with Prettier. diff --git a/src/lib.ts b/src/lib.ts index ae93062..14f683c 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -349,6 +349,22 @@ function resolveStylesheet(stylesheetPath: string | null, base: string): string return stylesheetPath } +/** + * Creates a sorter instance for sorting Tailwind CSS classes. + * + * This function initializes a sorter with the specified Tailwind CSS configuration. + * The sorter can be used to sort class attributes (space-separated strings) or + * class lists (arrays of class names). + + * @example + * ```ts + * const sorter = await createSorter({}) + * + * // Sort class lists + * const sorted = sorter.sortClassLists([['p-4', 'm-2']]) + * // Returns: [['m-2', 'p-4']] + * ``` + */ export async function createSorter(opts: SorterOptions): Promise { let preserveDuplicates = opts.preserveDuplicates ?? false let preserveWhitespace = opts.preserveWhitespace ?? false diff --git a/src/sorter.ts b/src/sorter.ts deleted file mode 100644 index dcdf632..0000000 --- a/src/sorter.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createSorter } from './lib.js' -export type { Sorter, SorterOptions } from './lib.js' diff --git a/tests/sorter.test.ts b/tests/sorter.test.ts index 17e87be..1ceb616 100644 --- a/tests/sorter.test.ts +++ b/tests/sorter.test.ts @@ -7,16 +7,182 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) describe('createSorter', () => { - test('sorts with base + relative configPath', async () => { - let fixtureDir = path.resolve(__dirname, 'fixtures/basic') - let sorter = await createSorter({ - base: fixtureDir, - filepath: path.join(fixtureDir, 'index.html'), - configPath: './tailwind.config.js', + describe('sortClassAttributes', () => { + test('sorts with base + relative configPath (v3)', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/basic') + let sorter = await createSorter({ + base: fixtureDir, + filepath: path.join(fixtureDir, 'index.html'), + configPath: './tailwind.config.js', + }) + + let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) + expect(sorted).toBe('bg-red-500 sm:bg-tomato') + }) + + test('sorts with base + absolute configPath', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/basic') + let configPath = path.join(fixtureDir, 'tailwind.config.js') + let sorter = await createSorter({ + base: fixtureDir, + configPath, + }) + + let sorted = sorter.sortClassAttributes(['p-4 m-2', 'hover:text-red-500 text-blue-500']) + expect(sorted).toEqual(['m-2 p-4', 'text-blue-500 hover:text-red-500']) + }) + + test('sorts with v4 stylesheet', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/custom-pkg-name-v4') + let sorter = await createSorter({ + base: fixtureDir, + stylesheetPath: './app.css', + }) + + let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) + expect(sorted).toBe('bg-red-500 sm:bg-tomato') + }) + + test('preserves whitespace when option is enabled', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/basic') + let sorter = await createSorter({ + base: fixtureDir, + configPath: './tailwind.config.js', + preserveWhitespace: true, + }) + + let [sorted] = sorter.sortClassAttributes([' sm:bg-tomato bg-red-500 ']) + expect(sorted).toBe(' bg-red-500 sm:bg-tomato ') }) - let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) - expect(sorted).toBe('bg-red-500 sm:bg-tomato') + test('collapses whitespace by default', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/basic') + let sorter = await createSorter({ + base: fixtureDir, + configPath: './tailwind.config.js', + }) + + let [sorted] = sorter.sortClassAttributes([' sm:bg-tomato bg-red-500 ']) + expect(sorted).toBe('bg-red-500 sm:bg-tomato') + }) + + test('removes duplicates by default', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/basic') + let sorter = await createSorter({ + base: fixtureDir, + configPath: './tailwind.config.js', + }) + + let [sorted] = sorter.sortClassAttributes(['bg-red-500 sm:bg-tomato bg-red-500']) + expect(sorted).toBe('bg-red-500 sm:bg-tomato') + }) + + test('preserves duplicates when option is enabled', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/basic') + let sorter = await createSorter({ + base: fixtureDir, + configPath: './tailwind.config.js', + preserveDuplicates: true, + }) + + let [sorted] = sorter.sortClassAttributes(['bg-red-500 sm:bg-tomato bg-red-500']) + expect(sorted).toBe('bg-red-500 bg-red-500 sm:bg-tomato') + }) }) + describe('sortClassLists', () => { + test('sorts class lists (arrays of class names)', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/basic') + let sorter = await createSorter({ + base: fixtureDir, + configPath: './tailwind.config.js', + }) + + let sorted = sorter.sortClassLists([ + ['sm:bg-tomato', 'bg-red-500'], + ['p-4', 'm-2'], + ]) + + expect(sorted).toEqual([ + ['bg-red-500', 'sm:bg-tomato'], + ['m-2', 'p-4'], + ]) + }) + + test('removes duplicates by default (replaces with null)', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/basic') + let sorter = await createSorter({ + base: fixtureDir, + configPath: './tailwind.config.js', + }) + + let [sorted] = sorter.sortClassLists([['bg-red-500', 'sm:bg-tomato', 'bg-red-500']]) + + expect(sorted).toEqual(['bg-red-500', 'sm:bg-tomato', null]) + }) + + test('preserves duplicates when option is enabled', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/basic') + let sorter = await createSorter({ + base: fixtureDir, + configPath: './tailwind.config.js', + preserveDuplicates: true, + }) + + let [sorted] = sorter.sortClassLists([['bg-red-500', 'sm:bg-tomato', 'bg-red-500']]) + + expect(sorted).toEqual(['bg-red-500', 'bg-red-500', 'sm:bg-tomato']) + }) + }) + + describe('error handling', () => { + test('handles auto-detection without explicit config', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/basic') + let sorter = await createSorter({ + base: fixtureDir, + filepath: path.join(fixtureDir, 'index.html'), + }) + + let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) + expect(sorted).toBe('bg-red-500 sm:bg-tomato') + }) + + test('works with no tailwind installation (uses bundled)', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/no-local-version') + let sorter = await createSorter({ + base: fixtureDir, + stylesheetPath: './app.css', + }) + + let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) + expect(sorted).toBe('bg-red-500 sm:bg-tomato') + }) + + test('works without a config file (uses default Tailwind config)', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/no-stylesheet-given') + let sorter = await createSorter({ + base: fixtureDir, + }) + + // Should still sort using default Tailwind order + let [sorted] = sorter.sortClassAttributes(['p-4 m-2']) + expect(sorted).toBe('m-2 p-4') + }) + }) + + describe('monorepo support', () => { + test('resolves tailwind relative to filepath in monorepo', async () => { + let fixtureDir = path.resolve(__dirname, 'fixtures/monorepo') + let package1Path = path.join(fixtureDir, 'package-1', 'index.html') + + let sorter = await createSorter({ + base: path.join(fixtureDir, 'package-1'), + filepath: package1Path, + stylesheetPath: './app.css', + }) + + let [sorted] = sorter.sortClassAttributes(['sm:bg-tomato bg-red-500']) + expect(sorted).toBe('bg-red-500 sm:bg-tomato') + }) + }) }) From b5ce3d574133a48766c4737bdd6a4e8c59da6b4b Mon Sep 17 00:00:00 2001 From: Dunqing Date: Thu, 5 Feb 2026 15:40:54 +0800 Subject: [PATCH 13/15] Fix failing test --- src/lib.ts | 11 +++-------- tests/sorter.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/lib.ts b/src/lib.ts index 14f683c..a173b8e 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -82,9 +82,9 @@ export interface Sorter { * Each element is an array of class names. Passing a space separated class * list in each element is not supported. * - * When removing duplicates they are replaced with `null`. + * Duplicates are removed by default unless `preserveDuplicates` is enabled. */ - sortClassLists(classes: string[][]): (string | null)[][] + sortClassLists(classes: string[][]): string[][] } type TailwindConfigOptions = { @@ -397,12 +397,7 @@ export async function createSorter(opts: SorterOptions): Promise { removeDuplicates: !preserveDuplicates, }) - let sorted: (string | null)[] = [...result.classList] - for (let idx of result.removedIndices) { - sorted[idx] = null - } - - return sorted + return result.classList }) }, diff --git a/tests/sorter.test.ts b/tests/sorter.test.ts index 1ceb616..a4acc9c 100644 --- a/tests/sorter.test.ts +++ b/tests/sorter.test.ts @@ -109,7 +109,7 @@ describe('createSorter', () => { ]) }) - test('removes duplicates by default (replaces with null)', async () => { + test('removes duplicates by default', async () => { let fixtureDir = path.resolve(__dirname, 'fixtures/basic') let sorter = await createSorter({ base: fixtureDir, @@ -118,7 +118,7 @@ describe('createSorter', () => { let [sorted] = sorter.sortClassLists([['bg-red-500', 'sm:bg-tomato', 'bg-red-500']]) - expect(sorted).toEqual(['bg-red-500', 'sm:bg-tomato', null]) + expect(sorted).toEqual(['bg-red-500', 'sm:bg-tomato']) }) test('preserves duplicates when option is enabled', async () => { From 122c455c368ebad1c1ec53eb64a2ab2baca43270 Mon Sep 17 00:00:00 2001 From: Dunqing Date: Thu, 5 Feb 2026 15:54:37 +0800 Subject: [PATCH 14/15] Extract cacheForDirs to shared utilities --- src/config.ts | 3 ++- src/lib.ts | 38 +------------------------------------- src/utils.ts | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 38 deletions(-) diff --git a/src/config.ts b/src/config.ts index b9c6385..f642555 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,8 +4,9 @@ import prettier from 'prettier' import type { ParserOptions } from 'prettier' import * as console from './console' import { expiringMap } from './expiring-map.js' -import { cacheForDirs, getTailwindConfig as getTailwindConfigFromLib } from './lib.js' +import { getTailwindConfig as getTailwindConfigFromLib } from './lib.js' import type { UnifiedApi } from './types' +import { cacheForDirs } from './utils.js' let prettierConfigCache = expiringMap(10_000) diff --git a/src/lib.ts b/src/lib.ts index a173b8e..f95ee37 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -7,6 +7,7 @@ import { expiringMap } from './expiring-map.js' import { resolveJsFrom } from './resolve' import { sortClasses, sortClassList } from './sorting.js' import type { TransformerEnv, UnifiedApi } from './types' +import { cacheForDirs } from './utils.js' import { loadV3 } from './versions/v3' import { loadV4 } from './versions/v4' @@ -100,43 +101,6 @@ function resolveIfRelative(base: string, filePath?: string) { return path.isAbsolute(filePath) ? filePath : path.resolve(base, filePath) } -/** - * Cache a value for all directories from `inputDir` up to `targetDir` (inclusive). - * Stops early if an existing cache entry is found. - * - * How it works: - * - * For a file at '/repo/packages/ui/src/Button.tsx' with config at '/repo/package.json' - * - * `cacheForDirs(cache, '/repo/packages/ui/src', '/repo/package.json', '/repo')` - * - * Caches: - * - '/repo/packages/ui/src' -> '/repo/package.json' - * - '/repo/packages/ui' -> '/repo/package.json' - * - '/repo/packages' -> '/repo/package.json' - * - '/repo' -> '/repo/package.json' - * - * @internal - */ -export function cacheForDirs( - cache: { set(key: string, value: V): void; get(key: string): V | undefined }, - inputDir: string, - value: V, - targetDir: string, - makeKey: (dir: string) => string = (dir) => dir, -): void { - let dir = inputDir - while (dir !== path.dirname(dir) && dir.length >= targetDir.length) { - const key = makeKey(dir) - // Stop caching if we hit an existing entry - if (cache.get(key) !== undefined) break - - cache.set(key, value) - if (dir === targetDir) break - dir = path.dirname(dir) - } -} - let pathToApiMap = expiringMap>(10_000) /** diff --git a/src/utils.ts b/src/utils.ts index 8fb8c51..770f3f9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,4 @@ +import * as path from 'node:path' import type { StringChange } from './types' // For loading prettier plugins only if they exist @@ -138,3 +139,38 @@ export function spliceChangesIntoString(str: string, changes: StringChange[]) { export function bigSign(bigIntValue: bigint) { return Number(bigIntValue > 0n) - Number(bigIntValue < 0n) } + +/** + * Cache a value for all directories from `inputDir` up to `targetDir` (inclusive). + * Stops early if an existing cache entry is found. + * + * How it works: + * + * For a file at '/repo/packages/ui/src/Button.tsx' with config at '/repo/package.json' + * + * `cacheForDirs(cache, '/repo/packages/ui/src', '/repo/package.json', '/repo')` + * + * Caches: + * - '/repo/packages/ui/src' -> '/repo/package.json' + * - '/repo/packages/ui' -> '/repo/package.json' + * - '/repo/packages' -> '/repo/package.json' + * - '/repo' -> '/repo/package.json' + */ +export function cacheForDirs( + cache: { set(key: string, value: V): void; get(key: string): V | undefined }, + inputDir: string, + value: V, + targetDir: string, + makeKey: (dir: string) => string = (dir) => dir, +): void { + let dir = inputDir + while (dir !== path.dirname(dir) && dir.length >= targetDir.length) { + const key = makeKey(dir) + // Stop caching if we hit an existing entry + if (cache.get(key) !== undefined) break + + cache.set(key, value) + if (dir === targetDir) break + dir = path.dirname(dir) + } +} From 82e061fe08c6217094cc104a630b62c16a7858fa Mon Sep 17 00:00:00 2001 From: Dunqing Date: Thu, 5 Feb 2026 16:02:14 +0800 Subject: [PATCH 15/15] Export to `/sorter` --- README.md | 4 ++-- package.json | 8 ++++---- src/config.ts | 2 +- src/{lib.ts => sorter.ts} | 1 - tests/sorter.test.ts | 2 +- tsdown.config.ts | 2 +- 6 files changed, 9 insertions(+), 10 deletions(-) rename src/{lib.ts => sorter.ts} (99%) diff --git a/README.md b/README.md index 286c16a..f6caa8e 100644 --- a/README.md +++ b/README.md @@ -195,10 +195,10 @@ const mySortedClasses = tw`bg-white p-4 dark:bg-black` ## Public API If you want to use the Tailwind class sorting logic outside of Prettier, import from the -`lib` entrypoint: +`sorter` entrypoint: ```js -import { createSorter } from 'prettier-plugin-tailwindcss/lib' +import { createSorter } from 'prettier-plugin-tailwindcss/sorter' let sorter = await createSorter({ base: '/path/to/project', diff --git a/package.json b/package.json index 3763af0..d426cd8 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,10 @@ "import": "./dist/index.mjs", "default": "./dist/index.mjs" }, - "./lib": { - "types": "./dist/lib.d.mts", - "import": "./dist/lib.mjs", - "default": "./dist/lib.mjs" + "./sorter": { + "types": "./dist/sorter.d.mts", + "import": "./dist/sorter.mjs", + "default": "./dist/sorter.mjs" } }, "repository": { diff --git a/src/config.ts b/src/config.ts index f642555..fed19e3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,7 @@ import prettier from 'prettier' import type { ParserOptions } from 'prettier' import * as console from './console' import { expiringMap } from './expiring-map.js' -import { getTailwindConfig as getTailwindConfigFromLib } from './lib.js' +import { getTailwindConfig as getTailwindConfigFromLib } from './sorter.js' import type { UnifiedApi } from './types' import { cacheForDirs } from './utils.js' diff --git a/src/lib.ts b/src/sorter.ts similarity index 99% rename from src/lib.ts rename to src/sorter.ts index f95ee37..13879f0 100644 --- a/src/lib.ts +++ b/src/sorter.ts @@ -1,4 +1,3 @@ -// @ts-check import * as path from 'node:path' import { pathToFileURL } from 'node:url' import escalade from 'escalade/sync' diff --git a/tests/sorter.test.ts b/tests/sorter.test.ts index a4acc9c..3851623 100644 --- a/tests/sorter.test.ts +++ b/tests/sorter.test.ts @@ -1,7 +1,7 @@ import * as path from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, test } from 'vitest' -import { createSorter } from '../src/lib' +import { createSorter } from '../src/sorter' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) diff --git a/tsdown.config.ts b/tsdown.config.ts index 3b80c84..e33215c 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -93,7 +93,7 @@ function inlineCssImports(): Rolldown.Plugin { } export default defineConfig({ - entry: ['./src/index.ts', './src/lib.ts'], + entry: ['./src/index.ts', './src/sorter.ts'], format: 'esm', platform: 'node', target: 'node14.21.3',