From 8ece5f273ef1b2c03ead7aa14b1acb98618977fb Mon Sep 17 00:00:00 2001 From: Yuji Sugiura Date: Mon, 18 May 2026 16:45:31 +0900 Subject: [PATCH 1/2] Preserve escape sequences when sorting JS string literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When sorting class strings inside a JS string literal whose outer quote is fixed by the surrounding context (e.g. a Vue `:class="cn('...')"` attribute), the sorter operated on the cooked value and then wrote it back as raw without re-encoding, dropping JS escapes like `\'` and producing invalid JavaScript that failed the next parse. Sort the raw source representation directly so escape sequences ride along with their tokens, mirroring how `sortTemplateLiteral` already handles quasis. This also removes the need to discriminate JS literals from JSX attribute values (where backslashes are literal and divergence between raw and cooked is driven by HTML entity decoding, not escapes). The trade-off: literals that use a whitespace escape sequence as the class separator look like one non-whitespace token in raw form, so we skip sorting them. This shape has no realistic callers and rewriting it would re-introduce the cooked → raw re-encoding we just eliminated. --- CHANGELOG.md | 4 ++- src/index.ts | 58 ++++++++++++++++++------------------------ tests/format.test.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eafe5bc..bdc5e7c 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 ## [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) + }) +}) From d19e2037366253b0cfbd12f58d10d84dddba2dde Mon Sep 17 00:00:00 2001 From: Yuji Sugiura <6259812+leaysgur@users.noreply.github.com> Date: Mon, 25 May 2026 13:03:26 +0900 Subject: [PATCH 2/2] Update changelog entry for issue #461 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdc5e7c..88518d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 +- 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