Skip to content

Commit 30f8d8e

Browse files
Fix whitespace removal inside nested concat and template expressions (#396)
* Refactor * Bail out of whitespace removal inside nested concat expressions * Bail out of whitespace removal inside template literals based on position * Update changelog * Refactor * Fix angular expression parsing with concat statements * Tweak test
1 parent fa61eae commit 30f8d8e

File tree

5 files changed

+84
-48
lines changed

5 files changed

+84
-48
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- Handle quote escapes in LESS when sorting `@apply` ([#392](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/392))
1616
- Improved monorepo support by loading Tailwind CSS relative to the input file instead of prettier config file ([#386](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/386))
1717
- Improved monorepo support by loading v3 configs relative to the input file instead of prettier config file ([#386](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/386))
18+
- Fix whitespace removal inside nested concat and template expressions ([#396](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/396))
1819

1920
## [0.6.14] - 2025-07-09
2021

src/index.ts

Lines changed: 55 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { getCustomizations } from './options.js'
1515
import { loadPlugins } from './plugins.js'
1616
import { sortClasses, sortClassList } from './sorting.js'
1717
import type { Customizations, StringChange, TransformerContext, TransformerEnv, TransformerMetadata } from './types'
18-
import { spliceChangesIntoString, visit } from './utils.js'
18+
import { spliceChangesIntoString, visit, type Path } from './utils.js'
1919

2020
let base = await loadPlugins()
2121

@@ -109,30 +109,23 @@ function transformDynamicAngularAttribute(attr: any, env: TransformerEnv) {
109109
StringLiteral(node, path) {
110110
if (!node.value) return
111111

112-
let concat = path.find((entry) => {
113-
return entry.parent && entry.parent.type === 'BinaryExpression' && entry.parent.operator === '+'
114-
})
112+
let collapseWhitespace = canCollapseWhitespaceIn(path)
115113

116114
changes.push({
117115
start: node.start + 1,
118116
end: node.end - 1,
119117
before: node.value,
120118
after: sortClasses(node.value, {
121119
env,
122-
collapseWhitespace: {
123-
start: concat?.key !== 'right',
124-
end: concat?.key !== 'left',
125-
},
120+
collapseWhitespace,
126121
}),
127122
})
128123
},
129124

130125
TemplateLiteral(node, path) {
131126
if (!node.quasis.length) return
132127

133-
let concat = path.find((entry) => {
134-
return entry.parent && entry.parent.type === 'BinaryExpression' && entry.parent.operator === '+'
135-
})
128+
let collapseWhitespace = canCollapseWhitespaceIn(path)
136129

137130
for (let i = 0; i < node.quasis.length; i++) {
138131
let quasi = node.quasis[i]
@@ -152,8 +145,8 @@ function transformDynamicAngularAttribute(attr: any, env: TransformerEnv) {
152145
ignoreLast: i < node.expressions.length && !/\s$/.test(quasi.value.raw),
153146

154147
collapseWhitespace: {
155-
start: concat?.key !== 'right' && i === 0,
156-
end: concat?.key !== 'left' && i >= node.expressions.length,
148+
start: collapseWhitespace.start && i === 0,
149+
end: collapseWhitespace.end && i >= node.expressions.length,
157150
},
158151
}),
159152
})
@@ -612,6 +605,49 @@ function isSortableExpression(
612605
return false
613606
}
614607

608+
function canCollapseWhitespaceIn(path: Path<import('@babel/types').Node, any>) {
609+
let start = true
610+
let end = true
611+
612+
for (let entry of path) {
613+
if (!entry.parent) continue
614+
615+
// Nodes inside concat expressions shouldn't collapse whitespace
616+
// depending on which side they're part of.
617+
if (entry.parent.type === 'BinaryExpression' && entry.parent.operator === '+') {
618+
start &&= entry.key !== 'right'
619+
end &&= entry.key !== 'left'
620+
}
621+
622+
// This is probably expression *inside* of a template literal. To collapse whitespace
623+
// `Expression`s adjacent-before a quasi must start with whitespace
624+
// `Expression`s adjacent-after a quasi must end with whitespace
625+
//
626+
// Note this check will bail out on more than it really should as it
627+
// could be reset somewhere along the way by having whitespace around a
628+
// string further up but not at the "root" but that complicates things
629+
if (entry.parent.type === 'TemplateLiteral') {
630+
let nodeStart = entry.node.start ?? null
631+
let nodeEnd = entry.node.end ?? null
632+
633+
for (let quasi of entry.parent.quasis) {
634+
let quasiStart = quasi.end ?? null
635+
let quasiEnd = quasi.end ?? null
636+
637+
if (nodeStart !== null && quasiEnd !== null && nodeStart - quasiEnd <= 2) {
638+
start &&= /^\s/.test(quasi.value.raw)
639+
}
640+
641+
if (nodeEnd !== null && quasiStart !== null && nodeEnd - quasiStart <= 2) {
642+
end &&= /\s$/.test(quasi.value.raw)
643+
}
644+
}
645+
}
646+
}
647+
648+
return { start, end }
649+
}
650+
615651
// TODO: The `ast` types here aren't strictly correct.
616652
//
617653
// We cross several parsers that share roughly the same shape so things are
@@ -621,35 +657,15 @@ function transformJavaScript(ast: import('@babel/types').Node, { env }: Transfor
621657

622658
function sortInside(ast: import('@babel/types').Node) {
623659
visit(ast, (node, path) => {
624-
let concat = path.find((entry) => {
625-
return entry.parent && entry.parent.type === 'BinaryExpression' && entry.parent.operator === '+'
626-
})
660+
let collapseWhitespace = canCollapseWhitespaceIn(path)
627661

628662
if (isStringLiteral(node)) {
629-
sortStringLiteral(node, {
630-
env,
631-
collapseWhitespace: {
632-
start: concat?.key !== 'right',
633-
end: concat?.key !== 'left',
634-
},
635-
})
663+
sortStringLiteral(node, { env, collapseWhitespace })
636664
} else if (node.type === 'TemplateLiteral') {
637-
sortTemplateLiteral(node, {
638-
env,
639-
collapseWhitespace: {
640-
start: concat?.key !== 'right',
641-
end: concat?.key !== 'left',
642-
},
643-
})
665+
sortTemplateLiteral(node, { env, collapseWhitespace })
644666
} else if (node.type === 'TaggedTemplateExpression') {
645667
if (isSortableTemplateExpression(node, functions)) {
646-
sortTemplateLiteral(node.quasi, {
647-
env,
648-
collapseWhitespace: {
649-
start: concat?.key !== 'right',
650-
end: concat?.key !== 'left',
651-
},
652-
})
668+
sortTemplateLiteral(node.quasi, { env, collapseWhitespace })
653669
}
654670
}
655671
})
@@ -697,16 +713,11 @@ function transformJavaScript(ast: import('@babel/types').Node, { env }: Transfor
697713
return
698714
}
699715

700-
let concat = path.find((entry) => {
701-
return entry.parent && entry.parent.type === 'BinaryExpression' && entry.parent.operator === '+'
702-
})
716+
let collapseWhitespace = canCollapseWhitespaceIn(path)
703717

704718
sortTemplateLiteral(node.quasi, {
705719
env,
706-
collapseWhitespace: {
707-
start: concat?.key !== 'right',
708-
end: concat?.key !== 'left',
709-
},
720+
collapseWhitespace,
710721
})
711722
},
712723
})

src/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ interface PathEntry<T, Meta> {
1919
meta: Meta
2020
}
2121

22-
type Path<T, Meta> = PathEntry<T, Meta>[]
22+
export type Path<T, Meta> = PathEntry<T, Meta>[]
2323

2424
type Visitor<T, Meta extends Record<string, unknown>> = (
2525
node: T,

tests/format.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,30 @@ describe('whitespace', () => {
5858
expect(result).toEqual(';<div className={`text-red-500 underline ${foo}-bar flex`}></div>')
5959
})
6060

61+
test('whitespace is not trimmed inside concat expressions', async ({ expect }) => {
62+
let result = await format(";<div className={a + ' p-4 ' + b}></div>", {
63+
parser: 'babel',
64+
})
65+
66+
expect(result).toEqual(";<div className={a + ' p-4 ' + b}></div>")
67+
})
68+
69+
test('whitespace is not trimmed inside concat expressions (angular)', async ({ expect }) => {
70+
let result = await format(`<ul [class]="'pagination' + (size ? ' pagination-' + size : '')"></ul>`, {
71+
parser: 'angular',
72+
})
73+
74+
expect(result).toEqual(`<ul [class]="'pagination' + (size ? ' pagination-' + size : '')"></ul>`)
75+
})
76+
77+
test('whitespace is not trimmed inside adjacent-before/after template expressions', async ({ expect }) => {
78+
let result = await format(";<div className={`header${isExtendable ? ' header-extendable' : ''}`} />", {
79+
parser: 'babel',
80+
})
81+
82+
expect(result).toEqual(";<div className={`header${isExtendable ? ' header-extendable' : ''}`} />")
83+
})
84+
6185
test('duplicate classes are dropped', async ({ expect }) => {
6286
let result = await format('<div class="underline line-through underline flex"></div>')
6387

tests/tests.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,8 @@ export let tests: Record<string, TestEntry[]> = {
207207
t`<div [ngClass]="{ '${yes}': foo && bar?.['baz'] }" class="${yes}"></div>`,
208208

209209
[
210-
`<div [ngClass]="' flex ' + ' underline ' + ' block '"></div>`,
211-
`<div [ngClass]="'flex ' + ' underline' + ' block'"></div>`,
210+
`<div [ngClass]="' flex ' + ' italic underline ' + ' block '"></div>`,
211+
`<div [ngClass]="'flex ' + ' italic underline ' + ' block'"></div>`,
212212
],
213213

214214
// TODO: Enable this test — it causes console noise but not a failure
@@ -238,7 +238,7 @@ export let tests: Record<string, TestEntry[]> = {
238238
...css,
239239
t`@apply ${yes} !important;`,
240240
t`@apply ~"${yes}";`,
241-
t`@apply ~'${yes}';`
241+
t`@apply ~'${yes}';`,
242242
],
243243
babel: javascript,
244244
typescript: javascript,

0 commit comments

Comments
 (0)