diff --git a/CHANGELOG.md b/CHANGELOG.md index eafe5bc..88518d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Fixed + +- Preserve escape sequences when sorting JS string literals so escaped quotes inside attribute-bound class strings (e.g. Vue `:class="cn('...')"`) round-trip correctly (#461) ## [0.8.0] - 2026-04-27 diff --git a/src/index.ts b/src/index.ts index fc5e673..36965df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,6 @@ import { defineTransform } from './transform.js' import type { StringChange, TransformerEnv } from './types' import { spliceChangesIntoString, visit, type Path } from './utils.js' -const ESCAPE_SEQUENCE_PATTERN = /\\(['"\\nrtbfv0-7xuU])/g function tryParseAngularAttribute(value: string, env: TransformerEnv) { try { return prettierParserAngular.parsers.__ng_directive.parse(value, env.options) @@ -415,43 +414,36 @@ function sortStringLiteral( collapseWhitespace?: false | { start: boolean; end: boolean } }, ) { - let result = sortClasses(node.value, { - env, - removeDuplicates, - collapseWhitespace, - }) - - let didChange = result !== node.value - - if (!didChange) return false - - node.value = result - - // Preserve the original escaping level for the new content + // Sort the raw source representation directly + // so escape sequences ride along with their tokens. + // This mirrors how `sortTemplateLiteral` handles quasis + // and lets us avoid a fragile cooked → raw re-encoding pass. + // + // Trade-off: + // Literals that use a whitespace escape sequence (e.g. `\n` as a JS escape) as the class separator + // look like a single non-whitespace token in raw form, so we skip sorting them. + // Rewriting the raw from the sorted cooked value would require re-introducing the cooked → raw re-encoding, + // and with it the JSX/HTML-entity discrimination problem. let raw = node.extra?.raw ?? node.raw let quote = raw[0] - let originalRawContent = raw.slice(1, -1) - let originalValue = node.extra?.rawValue ?? node.value + let rawContent = raw.slice(1, -1) - if (node.extra) { - // The original list has ecapes so we ensure that the sorted list also - // maintains those by replacing backslashes from escape sequences. - // - // It seems that TypeScript-based ASTs don't need this special handling - // which is why this is guarded inside the `node.extra` check - if (originalRawContent !== originalValue && originalValue.includes('\\')) { - result = result.replace(ESCAPE_SEQUENCE_PATTERN, '\\\\$1') - } + let sortedRaw = sortClasses(rawContent, { env, removeDuplicates, collapseWhitespace }) + if (sortedRaw === rawContent) return false - // JavaScript (StringLiteral) - node.extra = { - ...node.extra, - rawValue: result, - raw: quote + result + quote, - } + // Reuse the raw sort when raw and cooked are byte-identical (no escapes). + // Avoids a second `getClassOrder` pass, the dominant cost in `sortClasses`. + let sortedCooked = + rawContent === node.value + ? sortedRaw + : sortClasses(node.value, { env, removeDuplicates, collapseWhitespace }) + node.value = sortedCooked + + let newRaw = quote + sortedRaw + quote + if (node.extra) { + node.extra = { ...node.extra, rawValue: sortedCooked, raw: newRaw } } else { - // TypeScript (Literal) - node.raw = quote + result + quote + node.raw = newRaw } return true diff --git a/tests/format.test.ts b/tests/format.test.ts index 488ad85..25224a0 100644 --- a/tests/format.test.ts +++ b/tests/format.test.ts @@ -231,6 +231,27 @@ describe('regex matching', () => { expect(result).toEqual('
') }) + test('does not re-escape JSX attribute values that use HTML entities', async ({ expect }) => { + // JSX attribute values are pass-through: HTML entity references must + // survive sorting unchanged, not be decoded and re-escaped. + let result = await format(`;
`, { + parser: 'babel', + }) + + expect(result).toEqual(`;
`) + }) + + test('preserves escaped quotes inside JS string in Vue :class', async ({ expect }) => { + // Inside a Vue :class="..." attribute the JS string MUST stay single-quoted + // (outer attribute already uses "), so an inner ' has to remain escaped as \'. + let input = `` + let expected = `` + + let result = await format(input, { parser: 'vue' }) + + expect(result).toEqual(expected) + }) + test('works with Angular property bindings', async ({ expect }) => { let result = await format('
', { parser: 'angular', @@ -288,3 +309,42 @@ describe('regex matching', () => { }) }) }) + +describe('escape sequences in JS string literals', () => { + test('babel: JSX expression container with escaped quote in arbitrary variant', async ({ + expect, + }) => { + // Exercises sortStringLiteral via transformJavaScript directly + // (distinct from the Vue path that goes through transformDynamicJsAttribute). + let input = `;
` + let expected = `;
` + + let result = await format(input, { parser: 'babel' }) + + expect(result).toEqual(expected) + }) + + test('typescript: literal via tailwindFunctions preserves escaped quote', async ({ expect }) => { + // Exercises the `node.raw =` branch (TS Literal without `node.extra`). + let input = `let x = tw("text-sm [&_svg:not([class*=\\'size-\\'])]:size-4 flex p-2")` + let expected = `let x = tw("flex p-2 text-sm [&_svg:not([class*=\\'size-\\'])]:size-4")` + + let result = await format(input, { + parser: 'typescript', + tailwindFunctions: ['tw'], + }) + + expect(result).toEqual(expected) + }) + + test('babel: JS whitespace escape as class separator is not sorted', async ({ expect }) => { + // Documented trade-off: a string like "sm:p-0\np-0" has no whitespace in its raw form, + // so sortClasses sees it as a single token and skips it. + let input = `;
` + let expected = `;
` + + let result = await format(input, { parser: 'babel' }) + + expect(result).toEqual(expected) + }) +})