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 diff --git a/src/index.ts b/src/index.ts index efe934e..fc1cbc0 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() @@ -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] @@ -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, }, }), }) @@ -612,6 +605,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,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 }) } } }) @@ -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, }) }, }) 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, diff --git a/tests/format.test.ts b/tests/format.test.ts index 56d091e..f411a1e 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -58,6 +58,30 @@ 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('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', + }) + + expect(result).toEqual(";
      ") + }) + test('duplicate classes are dropped', async ({ expect }) => { let result = await format('
      ') diff --git a/tests/tests.ts b/tests/tests.ts index 9fe5717..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 @@ -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,