Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Handle quote escapes in LESS when sorting `@apply` ([#392](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/392))
- 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))
- 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))
- Fix whitespace removal inside nested concat and template expressions ([#396](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/pull/396))

## [0.6.14] - 2025-07-09

Expand Down
99 changes: 55 additions & 44 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { getCustomizations } from './options.js'
import { loadPlugins } from './plugins.js'
import { sortClasses, sortClassList } from './sorting.js'
import type { Customizations, StringChange, TransformerContext, TransformerEnv, TransformerMetadata } from './types'
import { spliceChangesIntoString, visit } from './utils.js'
import { spliceChangesIntoString, visit, type Path } from './utils.js'

let base = await loadPlugins()

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

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

changes.push({
start: node.start + 1,
end: node.end - 1,
before: node.value,
after: sortClasses(node.value, {
env,
collapseWhitespace: {
start: concat?.key !== 'right',
end: concat?.key !== 'left',
},
collapseWhitespace,
}),
})
},

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

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

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

collapseWhitespace: {
start: concat?.key !== 'right' && i === 0,
end: concat?.key !== 'left' && i >= node.expressions.length,
start: collapseWhitespace.start && i === 0,
end: collapseWhitespace.end && i >= node.expressions.length,
},
}),
})
Expand Down Expand Up @@ -612,6 +605,49 @@ function isSortableExpression(
return false
}

function canCollapseWhitespaceIn(path: Path<import('@babel/types').Node, any>) {
let start = true
let end = true

for (let entry of path) {
if (!entry.parent) continue

// Nodes inside concat expressions shouldn't collapse whitespace
// depending on which side they're part of.
if (entry.parent.type === 'BinaryExpression' && entry.parent.operator === '+') {
start &&= entry.key !== 'right'
end &&= entry.key !== 'left'
}

// This is probably expression *inside* of a template literal. To collapse whitespace
// `Expression`s adjacent-before a quasi must start with whitespace
// `Expression`s adjacent-after a quasi must end with whitespace
//
// Note this check will bail out on more than it really should as it
// could be reset somewhere along the way by having whitespace around a
// string further up but not at the "root" but that complicates things
if (entry.parent.type === 'TemplateLiteral') {
let nodeStart = entry.node.start ?? null
let nodeEnd = entry.node.end ?? null

for (let quasi of entry.parent.quasis) {
let quasiStart = quasi.end ?? null
let quasiEnd = quasi.end ?? null

if (nodeStart !== null && quasiEnd !== null && nodeStart - quasiEnd <= 2) {
start &&= /^\s/.test(quasi.value.raw)
}

if (nodeEnd !== null && quasiStart !== null && nodeEnd - quasiStart <= 2) {
end &&= /\s$/.test(quasi.value.raw)
}
}
}
}

return { start, end }
}

// TODO: The `ast` types here aren't strictly correct.
//
// We cross several parsers that share roughly the same shape so things are
Expand All @@ -621,35 +657,15 @@ function transformJavaScript(ast: import('@babel/types').Node, { env }: Transfor

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

if (isStringLiteral(node)) {
sortStringLiteral(node, {
env,
collapseWhitespace: {
start: concat?.key !== 'right',
end: concat?.key !== 'left',
},
})
sortStringLiteral(node, { env, collapseWhitespace })
} else if (node.type === 'TemplateLiteral') {
sortTemplateLiteral(node, {
env,
collapseWhitespace: {
start: concat?.key !== 'right',
end: concat?.key !== 'left',
},
})
sortTemplateLiteral(node, { env, collapseWhitespace })
} else if (node.type === 'TaggedTemplateExpression') {
if (isSortableTemplateExpression(node, functions)) {
sortTemplateLiteral(node.quasi, {
env,
collapseWhitespace: {
start: concat?.key !== 'right',
end: concat?.key !== 'left',
},
})
sortTemplateLiteral(node.quasi, { env, collapseWhitespace })
}
}
})
Expand Down Expand Up @@ -697,16 +713,11 @@ function transformJavaScript(ast: import('@babel/types').Node, { env }: Transfor
return
}

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

sortTemplateLiteral(node.quasi, {
env,
collapseWhitespace: {
start: concat?.key !== 'right',
end: concat?.key !== 'left',
},
collapseWhitespace,
})
},
})
Expand Down
2 changes: 1 addition & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ interface PathEntry<T, Meta> {
meta: Meta
}

type Path<T, Meta> = PathEntry<T, Meta>[]
export type Path<T, Meta> = PathEntry<T, Meta>[]

type Visitor<T, Meta extends Record<string, unknown>> = (
node: T,
Expand Down
24 changes: 24 additions & 0 deletions tests/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,30 @@ describe('whitespace', () => {
expect(result).toEqual(';<div className={`text-red-500 underline ${foo}-bar flex`}></div>')
})

test('whitespace is not trimmed inside concat expressions', async ({ expect }) => {
let result = await format(";<div className={a + ' p-4 ' + b}></div>", {
parser: 'babel',
})

expect(result).toEqual(";<div className={a + ' p-4 ' + b}></div>")
})

test('whitespace is not trimmed inside concat expressions (angular)', async ({ expect }) => {
let result = await format(`<ul [class]="'pagination' + (size ? ' pagination-' + size : '')"></ul>`, {
parser: 'angular',
})

expect(result).toEqual(`<ul [class]="'pagination' + (size ? ' pagination-' + size : '')"></ul>`)
})

test('whitespace is not trimmed inside adjacent-before/after template expressions', async ({ expect }) => {
let result = await format(";<div className={`header${isExtendable ? ' header-extendable' : ''}`} />", {
parser: 'babel',
})

expect(result).toEqual(";<div className={`header${isExtendable ? ' header-extendable' : ''}`} />")
})

test('duplicate classes are dropped', async ({ expect }) => {
let result = await format('<div class="underline line-through underline flex"></div>')

Expand Down
6 changes: 3 additions & 3 deletions tests/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,8 @@ export let tests: Record<string, TestEntry[]> = {
t`<div [ngClass]="{ '${yes}': foo && bar?.['baz'] }" class="${yes}"></div>`,

[
`<div [ngClass]="' flex ' + ' underline ' + ' block '"></div>`,
`<div [ngClass]="'flex ' + ' underline' + ' block'"></div>`,
`<div [ngClass]="' flex ' + ' italic underline ' + ' block '"></div>`,
`<div [ngClass]="'flex ' + ' italic underline ' + ' block'"></div>`,
],

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