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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 25 additions & 33 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions tests/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,27 @@ describe('regex matching', () => {
expect(result).toEqual('<div :data-classes="`p-0 sm:p-0`"></div>')
})

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(`;<div className="sm:p-0 foo-&#34;bar&#34; p-0" />`, {
parser: 'babel',
})

expect(result).toEqual(`;<div className="foo-&#34;bar&#34; p-0 sm:p-0" />`)
})

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 = `<template><button :class="cn('text-sm [&_svg:not([class*=\\'size-\\'])]:size-4 flex p-2', props.class)"></button></template>`
let expected = `<template><button :class="cn('flex p-2 text-sm [&_svg:not([class*=\\'size-\\'])]:size-4', props.class)"></button></template>`

let result = await format(input, { parser: 'vue' })

expect(result).toEqual(expected)
})

test('works with Angular property bindings', async ({ expect }) => {
let result = await format('<div [dataClasses]="`sm:p-0 p-0`"></div>', {
parser: 'angular',
Expand Down Expand Up @@ -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 = `;<div className={"text-sm [&_svg:not([class*=\\'size-\\'])]:size-4 flex p-2"} />`
let expected = `;<div className={"flex p-2 text-sm [&_svg:not([class*=\\'size-\\'])]:size-4"} />`

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 = `;<div className={"sm:p-0\\np-0"} />`
let expected = `;<div className={'sm:p-0\\np-0'} />`

let result = await format(input, { parser: 'babel' })

expect(result).toEqual(expected)
})
})
Loading