Skip to content

Commit 57eac03

Browse files
committed
feat: Improve Tailwind class formatting by unifying multi-line expansion logic, adding nesting depth calculation, and removing custom template literal printer.
1 parent ce30eb5 commit 57eac03

File tree

9 files changed

+125
-264
lines changed

9 files changed

+125
-264
lines changed

src/categorizer.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ export function categorizeTailwindClasses(
3333
closingIndent?: string,
3434
sorter?: (classList: string[]) => string[]
3535
): string {
36+
// Ensure class names are clean
37+
classList = classList.map(c => c.trim()).filter(Boolean)
38+
3639
if (classList.length === 0) {
3740
return ''
3841
}
@@ -266,12 +269,12 @@ function formatCategoryWithLineWrapping(
266269

267270
while (remainingGroups.length > 0) {
268271
let currentLineGroups = [...remainingGroups]
269-
let currentLineString = currentLineGroups.map((g) => g.join(' ')).join(' ')
272+
let currentLineString = currentLineGroups.map((g) => g.map(c => c.trim()).join(' ')).join(' ')
270273

271274
// If line exceeds printWidth and has more than one group, remove the last group
272275
while (currentLineString.length > effectivePrintWidth && currentLineGroups.length > 1) {
273276
currentLineGroups.pop()
274-
currentLineString = currentLineGroups.map((g) => g.join(' ')).join(' ')
277+
currentLineString = currentLineGroups.map((g) => g.map(c => c.trim()).join(' ')).join(' ')
275278
}
276279

277280
// Add the line

src/index.ts

Lines changed: 34 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ function sortStringLiteral(
479479
env,
480480
removeDuplicates,
481481
collapseWhitespace,
482-
column: column ?? node.loc?.start?.column,
482+
column: column,
483483
})
484484

485485
let didChange = result !== node.value
@@ -672,6 +672,38 @@ function canCollapseWhitespaceIn(path: Path<import('@babel/types').Node, any>) {
672672
//
673673
// We cross several parsers that share roughly the same shape so things are
674674
// good enough. The actual AST we should be using is probably estree + ts.
675+
function getNestingDepth(path: any[]): number {
676+
let depth = 0
677+
for (const entry of path) {
678+
const node = entry.node || entry
679+
if (node) {
680+
if (
681+
node.type === 'JSXElement' ||
682+
node.type === 'JSXFragment' ||
683+
node.type === 'BlockStatement' ||
684+
node.type === 'ObjectExpression' ||
685+
node.type === 'ArrayExpression' ||
686+
node.type === 'ClassBody' ||
687+
node.type === 'SwitchCase' ||
688+
node.type === 'TSModuleBlock'
689+
) {
690+
depth++
691+
} else if (
692+
node.type === 'ArrowFunctionExpression' &&
693+
node.body.type !== 'BlockStatement'
694+
) {
695+
depth++
696+
} else if (
697+
node.type === 'ConditionalExpression' ||
698+
node.type === 'LogicalExpression'
699+
) {
700+
depth++
701+
}
702+
}
703+
}
704+
return depth
705+
}
706+
675707
function transformJavaScript(ast: import('@babel/types').Node, { env }: TransformerContext) {
676708
let { matcher } = env
677709

@@ -758,7 +790,7 @@ function transformJavaScript(ast: import('@babel/types').Node, { env }: Transfor
758790
// Or simply: indent = (depth + 1) * tabWidth.
759791
// getNestingDepth includes the JSXElement itself. So depth is N+1 (if we count 1 for Element).
760792
// If we use base depth of JSXElement, then attribute is +1 level deep.
761-
const depth = getNestingDepth(path) + 1
793+
const depth = getNestingDepth(path)
762794

763795
if (isStringLiteral(node.value)) {
764796
sortStringLiteral(node.value, { env, column: depth })
@@ -1197,116 +1229,6 @@ export const printers: Record<string, Printer> = (function () {
11971229
printers['svelte-ast'] = printer
11981230
}
11991231

1200-
// Helper function to traverse and modify Prettier Doc structure
1201-
function traverseDoc(doc: any, callback: (doc: any, parent: any, key: string | number) => any): any {
1202-
if (!doc || typeof doc !== 'object') {
1203-
return doc
1204-
}
1205-
1206-
// Arrays in Doc (like concat)
1207-
if (Array.isArray(doc)) {
1208-
return doc.map((item, index) => {
1209-
const modified = callback(item, doc, index)
1210-
return modified !== undefined ? modified : traverseDoc(item, callback)
1211-
})
1212-
}
1213-
1214-
// Objects in Doc
1215-
const result: any = {}
1216-
for (const key in doc) {
1217-
if (doc.hasOwnProperty(key)) {
1218-
const modified = callback(doc[key], doc, key)
1219-
result[key] = modified !== undefined ? modified : traverseDoc(doc[key], callback)
1220-
}
1221-
}
1222-
return result
1223-
}
1224-
1225-
// Add estree printer for JSX/TSX template literal formatting
1226-
// estree is the printer used by babel and typescript parsers
1227-
if (base.printers['estree']) {
1228-
let original = base.printers['estree']
1229-
let printer = { ...original }
1230-
1231-
// Intercept print to handle template literals in className
1232-
const originalPrint = original.print
1233-
printer.print = function(path: any, options: any, print: any) {
1234-
const node = path.getValue()
1235-
1236-
// Check if this is a className attribute with template literal
1237-
if (node.type === 'JSXAttribute' &&
1238-
node.name?.name === 'className' &&
1239-
node.value?.type === 'JSXExpressionContainer' &&
1240-
node.value.expression?.type === 'TemplateLiteral' &&
1241-
options.useTailwindFormat) {
1242-
1243-
// Print the attribute normally first
1244-
let result = originalPrint.call(this, path, options, print)
1245-
1246-
// Modify the Doc to add newline before closing backtick
1247-
// In the Doc structure, the backtick is a standalone string "`"
1248-
// We need to find its parent array and insert a hardline before it
1249-
let backtickCount = 0 // Count backticks: first is opening, second is closing
1250-
1251-
function modifyDoc(doc: any, parent: any, key: string | number): any {
1252-
// If this is an array, check if it contains the closing backtick
1253-
if (Array.isArray(doc)) {
1254-
for (let i = 0; i < doc.length; i++) {
1255-
// Look for backtick strings
1256-
if (doc[i] === '`') {
1257-
backtickCount++
1258-
1259-
// Only modify the second backtick (closing one)
1260-
if (backtickCount === 2 && i >= 1 && typeof doc[i-1] !== 'undefined') {
1261-
1262-
// Calculate indent (one level less than classes)
1263-
const tabWidth = options.tabWidth || 2
1264-
const baseIndent = options.useTabs ? '\t' : ' '.repeat(tabWidth)
1265-
const closingIndent = baseIndent.repeat(3) // 3 levels for className backtick
1266-
1267-
// Insert hardline and indent before the backtick
1268-
doc.splice(i, 0,
1269-
{ type: 'line', hard: true, literal: true },
1270-
{ type: 'break-parent' },
1271-
closingIndent
1272-
)
1273-
1274-
return doc
1275-
}
1276-
}
1277-
1278-
// Recursively modify nested structures
1279-
const modified = modifyDoc(doc[i], doc, i)
1280-
if (modified !== undefined) {
1281-
doc[i] = modified
1282-
}
1283-
}
1284-
} else if (typeof doc === 'object' && doc !== null) {
1285-
// Recursively modify object properties
1286-
for (const key in doc) {
1287-
if (doc.hasOwnProperty(key)) {
1288-
const modified = modifyDoc(doc[key], doc, key)
1289-
if (modified !== undefined) {
1290-
doc[key] = modified
1291-
}
1292-
}
1293-
}
1294-
}
1295-
1296-
return undefined
1297-
}
1298-
1299-
modifyDoc(result, null, 0)
1300-
1301-
return result
1302-
}
1303-
1304-
return originalPrint.call(this, path, options, print)
1305-
}
1306-
1307-
printers['estree'] = printer
1308-
}
1309-
13101232
return printers
13111233
})()
13121234

src/sorting.ts

Lines changed: 16 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -49,73 +49,21 @@ export function sortClasses(
4949

5050
// When useTailwindFormat is enabled, use category-based formatting
5151
if (env.options.useTailwindFormat) {
52-
let parts = classStr.split(/([\t\r\f\n ]+)/)
53-
let classes = parts.filter((_, i) => i % 2 === 0).filter(Boolean)
52+
const originalHasNewline = classStr.includes('\n')
53+
const tabWidth = env.options.tabWidth || 2
54+
const useTabs = env.options.useTabs || false
55+
56+
// Collapse all whitespace to get a clean list of classes
57+
const classes = classStr.split(/[\s\t\n\r]+/).map(c => c.trim()).filter(Boolean)
5458

5559
if (classes.length === 0) {
5660
return classStr
5761
}
5862

59-
const tabWidth = env.options.tabWidth || 2
60-
const useTabs = env.options.useTabs || false
61-
const printWidth = env.options.printWidth || 80
63+
// Determine if it should be multi-line
64+
const shouldExpand = originalHasNewline || classes.length >= 5
6265

63-
// Check if the className already has newlines (multi-line)
64-
const hasNewline = classStr.includes('\n')
65-
66-
// For single-line className, check if it should be expanded to multi-line
67-
// Expand if there are 5+ classes (enough to benefit from categorization)
68-
if (!hasNewline) {
69-
// For single-line string, only expand if 5+ classes
70-
if (classes.length >= 5) {
71-
// Calculate indentation based on column if available
72-
let indent: string
73-
let closingIndent: string | undefined
74-
75-
if (column !== undefined && column >= 0) {
76-
const level = column
77-
if (useTabs) {
78-
indent = '\t'.repeat(level + 1)
79-
closingIndent = useClosingIndent ? '\t'.repeat(level) : undefined
80-
} else {
81-
indent = ' '.repeat((level + 1) * tabWidth)
82-
closingIndent = useClosingIndent ? ' '.repeat(level * tabWidth) : undefined
83-
}
84-
} else {
85-
const baseIndent = useTabs ? '\t' : ' '.repeat(tabWidth)
86-
indent = baseIndent.repeat(4) // Default fallback
87-
closingIndent = useClosingIndent ? baseIndent.repeat(3) : undefined
88-
}
89-
90-
// Extract custom categories and format
91-
let customCategories: Record<string, string> | undefined
92-
const categoriesPath = env.options.useTailwindFormatCategories
93-
94-
if (categoriesPath && typeof categoriesPath === 'string') {
95-
try {
96-
const fs = require('fs')
97-
const path = require('path')
98-
const resolvedPath = path.isAbsolute(categoriesPath)
99-
? categoriesPath
100-
: path.resolve(process.cwd(), categoriesPath)
101-
const fileContent = fs.readFileSync(resolvedPath, 'utf-8')
102-
customCategories = JSON.parse(fileContent)
103-
} catch (e) {
104-
console.warn(`Failed to load custom categories from ${categoriesPath}:`, e)
105-
}
106-
}
107-
108-
return categorizeTailwindClasses(
109-
classes,
110-
env,
111-
indent,
112-
customCategories,
113-
tabWidth,
114-
closingIndent
115-
)
116-
}
117-
} else {
118-
// Already multi-line - AWLAYS force logical indentation if column is available
66+
if (shouldExpand) {
11967
let indent: string
12068
let closingIndent: string | undefined
12169

@@ -129,25 +77,12 @@ export function sortClasses(
12977
closingIndent = useClosingIndent ? ' '.repeat(level * tabWidth) : undefined
13078
}
13179
} else {
132-
// Fallback to extraction ONLY if column is missing (e.g. non-JavaScript contexts)
133-
const indentMatch = classStr.match(/\n([ \t]+)/)
134-
if (indentMatch) {
135-
indent = indentMatch[1]
136-
if (useClosingIndent) {
137-
if (indent.includes('\t')) {
138-
closingIndent = indent.length > 1 ? indent.slice(0, -1) : ''
139-
} else {
140-
closingIndent = indent.length > tabWidth ? indent.slice(0, -tabWidth) : ''
141-
}
142-
}
143-
} else {
144-
const baseIndent = useTabs ? '\t' : ' '.repeat(tabWidth)
145-
indent = baseIndent.repeat(4)
146-
closingIndent = useClosingIndent ? baseIndent.repeat(3) : undefined
147-
}
80+
const baseIndent = useTabs ? '\t' : ' '.repeat(tabWidth)
81+
indent = baseIndent.repeat(4) // Fallback
82+
closingIndent = useClosingIndent ? baseIndent.repeat(3) : undefined
14883
}
14984

150-
// Extract custom categories if provided
85+
// Extract custom categories
15186
let customCategories: Record<string, string> | undefined
15287
const categoriesPath = env.options.useTailwindFormatCategories
15388

@@ -165,7 +100,6 @@ export function sortClasses(
165100
}
166101
}
167102

168-
// Categorize classes and return formatted string with indentation
169103
return categorizeTailwindClasses(
170104
classes,
171105
env,
@@ -174,6 +108,9 @@ export function sortClasses(
174108
tabWidth,
175109
closingIndent
176110
)
111+
} else {
112+
// Small single-line class list - use the clean collapsed version for subsequent sorting
113+
classStr = classes.join(' ')
177114
}
178115
}
179116

tests/categories/complex-jsx/complex-jsx.expected.tsx

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,27 @@ const Index = (props: IProps) => {
2020
return (
2121
<div
2222
className='
23-
flex flex-col
24-
items-center justify-center
25-
w-screen h-screen
26-
gap-4
27-
'
23+
flex flex-col
24+
items-center justify-center
25+
w-screen h-screen
26+
gap-4
27+
'
2828
>
2929
<AirplaneTiltIcon className='size-20'></AirplaneTiltIcon>
3030
<span>Permissions required</span>
3131
<div className='flex flex-col gap-3'>
3232
{keys.map((item) => (
3333
<button
3434
className='
35-
relative
36-
flex
37-
items-center justify-center
38-
w-72 h-10
39-
px-4
40-
rounded-3xl
41-
bg-std-100
42-
clickable capitalize
43-
'
35+
relative
36+
flex
37+
items-center justify-center
38+
w-72 h-10
39+
px-4
40+
rounded-3xl
41+
bg-std-100
42+
clickable capitalize
43+
'
4444
key={item}
4545
onClick={() => onItem(item)}
4646
>

tests/categories/custom-categories/custom-categories.expected.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ function Component() {
22
return (
33
<div
44
className="
5-
custom-btn
6-
primary-color
7-
large-size
8-
hover-effect shadow-md
9-
rounded-lg p-4 m-2
10-
"
5+
custom-btn
6+
primary-color
7+
large-size
8+
hover-effect shadow-md
9+
rounded-lg p-4 m-2
10+
"
1111
>
1212
Custom Categories Content
1313
</div>

0 commit comments

Comments
 (0)