From 0eb44616c83a3858604488651b1dccb407b5e72d Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Sep 2025 15:35:59 -0400 Subject: [PATCH 1/7] Refactor --- src/index.ts | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/index.ts b/src/index.ts index efe934e..77cbb2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -621,34 +621,37 @@ 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 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' + + break + } + } if (isStringLiteral(node)) { sortStringLiteral(node, { env, - collapseWhitespace: { - start: concat?.key !== 'right', - end: concat?.key !== 'left', - }, + collapseWhitespace: { start, end }, }) } else if (node.type === 'TemplateLiteral') { sortTemplateLiteral(node, { env, - collapseWhitespace: { - start: concat?.key !== 'right', - end: concat?.key !== 'left', - }, + collapseWhitespace: { start, end }, }) } else if (node.type === 'TaggedTemplateExpression') { if (isSortableTemplateExpression(node, functions)) { sortTemplateLiteral(node.quasi, { env, - collapseWhitespace: { - start: concat?.key !== 'right', - end: concat?.key !== 'left', - }, + collapseWhitespace: { start, end }, }) } } From 4b553d77f044248cad6d88b9b765392ff57d14ac Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Sep 2025 15:36:51 -0400 Subject: [PATCH 2/7] Bail out of whitespace removal inside nested concat expressions --- src/index.ts | 6 ++---- tests/format.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 77cbb2f..ca070d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -630,10 +630,8 @@ function transformJavaScript(ast: import('@babel/types').Node, { env }: Transfor // 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' - - break + start &&= entry.key !== 'right' + end &&= entry.key !== 'left' } } diff --git a/tests/format.test.ts b/tests/format.test.ts index 56d091e..5a48615 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -58,6 +58,14 @@ describe('whitespace', () => { expect(result).toEqual(';
') }) + test('whitespace is not trimmed inside concat expressions', async ({ expect }) => { + let result = await format(";
", { + parser: 'babel', + }) + + expect(result).toEqual(";
") + }) + test('duplicate classes are dropped', async ({ expect }) => { let result = await format('
') From f56c9ee5bc8a03090ee4c30de846965f71052a9b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Sep 2025 15:37:48 -0400 Subject: [PATCH 3/7] Bail out of whitespace removal inside template literals based on position --- src/index.ts | 25 +++++++++++++++++++++++++ tests/format.test.ts | 8 ++++++++ 2 files changed, 33 insertions(+) diff --git a/src/index.ts b/src/index.ts index ca070d1..c0797c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -633,6 +633,31 @@ function transformJavaScript(ast: import('@babel/types').Node, { env }: Transfor 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) + } + } + } } if (isStringLiteral(node)) { diff --git a/tests/format.test.ts b/tests/format.test.ts index 5a48615..eb7aeee 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -66,6 +66,14 @@ describe('whitespace', () => { expect(result).toEqual(";
") }) + test('whitespace is not trimmed inside adjacent-before/after template expressions', async ({ expect }) => { + let result = await format(";
", { + parser: 'babel', + }) + + expect(result).toEqual(";
") + }) + test('duplicate classes are dropped', async ({ expect }) => { let result = await format('
') From 0ae56966be9522cfa296bc6261a6fb822ffb3733 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Sep 2025 15:40:01 -0400 Subject: [PATCH 4/7] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 805324e..4a57edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 9d58143fb593316affd47ec5bc0c3367af5a8fcd Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Sep 2025 17:07:07 -0400 Subject: [PATCH 5/7] Refactor --- src/index.ts | 103 +++++++++++++++++++++++++-------------------------- src/utils.ts | 2 +- 2 files changed, 51 insertions(+), 54 deletions(-) diff --git a/src/index.ts b/src/index.ts index c0797c6..b015205 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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() @@ -152,8 +152,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, }, }), }) @@ -612,6 +612,49 @@ function isSortableExpression( return false } +function canCollapseWhitespaceIn(path: Path) { + 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 @@ -621,61 +664,15 @@ function transformJavaScript(ast: import('@babel/types').Node, { env }: Transfor function sortInside(ast: import('@babel/types').Node) { visit(ast, (node, path) => { - 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) - } - } - } - } + let collapseWhitespace = canCollapseWhitespaceIn(path) if (isStringLiteral(node)) { - sortStringLiteral(node, { - env, - collapseWhitespace: { start, end }, - }) + sortStringLiteral(node, { env, collapseWhitespace }) } else if (node.type === 'TemplateLiteral') { - sortTemplateLiteral(node, { - env, - collapseWhitespace: { start, end }, - }) + sortTemplateLiteral(node, { env, collapseWhitespace }) } else if (node.type === 'TaggedTemplateExpression') { if (isSortableTemplateExpression(node, functions)) { - sortTemplateLiteral(node.quasi, { - env, - collapseWhitespace: { start, end }, - }) + sortTemplateLiteral(node.quasi, { env, collapseWhitespace }) } } }) diff --git a/src/utils.ts b/src/utils.ts index 4201358..8fb8c51 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -19,7 +19,7 @@ interface PathEntry { meta: Meta } -type Path = PathEntry[] +export type Path = PathEntry[] type Visitor> = ( node: T, From ffe843aa95679238a8fd383de08c3c0c8f2a10a4 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 1 Sep 2025 17:08:14 -0400 Subject: [PATCH 6/7] Fix angular expression parsing with concat statements --- src/index.ts | 22 +++++----------------- tests/format.test.ts | 8 ++++++++ tests/tests.ts | 4 ++-- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/index.ts b/src/index.ts index b015205..fc1cbc0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -109,9 +109,7 @@ 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, @@ -119,10 +117,7 @@ function transformDynamicAngularAttribute(attr: any, env: TransformerEnv) { before: node.value, after: sortClasses(node.value, { env, - collapseWhitespace: { - start: concat?.key !== 'right', - end: concat?.key !== 'left', - }, + collapseWhitespace, }), }) }, @@ -130,9 +125,7 @@ function transformDynamicAngularAttribute(attr: any, env: TransformerEnv) { 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] @@ -720,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, }) }, }) diff --git a/tests/format.test.ts b/tests/format.test.ts index eb7aeee..f411a1e 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -66,6 +66,14 @@ describe('whitespace', () => { expect(result).toEqual(";
") }) + test('whitespace is not trimmed inside concat expressions (angular)', async ({ expect }) => { + let result = await format(`
    `, { + parser: 'angular', + }) + + expect(result).toEqual(`
      `) + }) + test('whitespace is not trimmed inside adjacent-before/after template expressions', async ({ expect }) => { let result = await format(";
      ", { parser: 'babel', diff --git a/tests/tests.ts b/tests/tests.ts index 9fe5717..0151f94 100644 --- a/tests/tests.ts +++ b/tests/tests.ts @@ -208,7 +208,7 @@ export let tests: Record = { [ `
      `, - `
      `, + `
      `, ], // TODO: Enable this test — it causes console noise but not a failure @@ -238,7 +238,7 @@ export let tests: Record = { ...css, t`@apply ${yes} !important;`, t`@apply ~"${yes}";`, - t`@apply ~'${yes}';` + t`@apply ~'${yes}';`, ], babel: javascript, typescript: javascript, From 0c9cc449a67d331675a9c37c5d42937e21975de2 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Tue, 2 Sep 2025 10:33:47 -0400 Subject: [PATCH 7/7] Tweak test --- tests/tests.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/tests.ts b/tests/tests.ts index 0151f94..410e9c2 100644 --- a/tests/tests.ts +++ b/tests/tests.ts @@ -207,8 +207,8 @@ export let tests: Record = { t`
      `, [ - `
      `, - `
      `, + `
      `, + `
      `, ], // TODO: Enable this test — it causes console noise but not a failure