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
59 changes: 59 additions & 0 deletions apps/oxfmt/test/tailwindcss.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1049,3 +1049,62 @@ describe("Tailwind CSS Sorting with `experimentalSortImports` enabled", () => {
expect(resultWithOption.errors).toStrictEqual([]);
});
});

describe("Tailwind CSS Sorting works with other options", () => {
test("should keep quotes with `singleQuote: false`", async () => {
const input = `
<div className={clsx('text-md before:content-["hello"]')}>Hello</div>;
<div className={clsx("text-md before:content-['hello']")}>Hello</div>;
`;

const result = await format("test.tsx", input, {
experimentalTailwindcss: {
functions: ["clsx"],
},
singleQuote: false,
});

expect(result.code).toMatchInlineSnapshot(`
"<div className={clsx('text-md before:content-["hello"]')}>Hello</div>;
<div className={clsx("text-md before:content-['hello']")}>Hello</div>;
"
`);
});

test("should keep quotes with default `singleQuote`", async () => {
const input = `
<div className={clsx('text-md before:content-["hello"]')}>Hello</div>;
<div className={clsx("text-md before:content-['hello']")}>Hello</div>;
`;

const result = await format("test.tsx", input, {
experimentalTailwindcss: {
functions: ["clsx"],
},
});

expect(result.code).toMatchInlineSnapshot(`
"<div className={clsx('text-md before:content-["hello"]')}>Hello</div>;
<div className={clsx("text-md before:content-['hello']")}>Hello</div>;
"
`);
});

test("should handle quotes with `jsxSingleQuote: true` correctly", async () => {
const input = `
<div className="text-md before:content-['hello']">Hello</div>;
<div className='text-md before:content-["hello"]'>Hello</div>;
`;

const result = await format("test.tsx", input, {
experimentalTailwindcss: {},
jsxSingleQuote: true,
});

expect(result.code).toMatchInlineSnapshot(`
"<div className="text-md before:content-['hello']">Hello</div>;
<div className='text-md before:content-["hello"]'>Hello</div>;
"
`);
});
});
10 changes: 9 additions & 1 deletion crates/oxc_formatter/src/utils/string.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::borrow::Cow;
use std::{borrow::Cow, ops::Deref};

use oxc_span::SourceType;
use oxc_syntax::identifier::{is_identifier_part, is_identifier_start};
Expand Down Expand Up @@ -65,6 +65,14 @@ pub struct CleanedStringLiteralText<'a> {
text: Cow<'a, str>,
}

impl Deref for CleanedStringLiteralText<'_> {
type Target = str;

fn deref(&self) -> &Self::Target {
&self.text
}
}

impl CleanedStringLiteralText<'_> {
pub fn width(&self) -> usize {
self.text.width()
Expand Down
35 changes: 31 additions & 4 deletions crates/oxc_formatter/src/utils/tailwindcss.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ use crate::{
write,
};

use super::string::{FormatLiteralStringToken, StringLiteralParentKind};

// ============================================================================
// Detection Functions
// ============================================================================
Expand Down Expand Up @@ -198,24 +200,48 @@ where
/// - With `preserve_whitespace`, outputs content unchanged
pub fn write_tailwind_string_literal<'a>(
string_literal: &AstNode<'a, StringLiteral<'a>>,
preserve_whitespace: bool,
ctx: TailwindContextEntry,
f: &mut Formatter<'_, 'a>,
) {
if preserve_whitespace {
let content = f.source_text().text_for(&string_literal.span.shrink(1));
debug_assert!(
!string_literal.value.is_empty(),
"Empty string literals should be skipped for Tailwind sorting"
);

let normalized_string = FormatLiteralStringToken::new(
f.source_text().text_for(&string_literal),
ctx.is_jsx,
StringLiteralParentKind::Expression,
)
.clean_text(f);

let quote = normalized_string.as_bytes()[0];
let quote = match quote {
b'\'' => "\'",
b'"' => "\"",
_ => unreachable!("Unexpected quote character in string literal"),
};

write!(f, quote);

// At least three characters: opening quote, content, closing quote
let content = &normalized_string[1..normalized_string.len() - 1];

if ctx.preserve_whitespace {
let index = f.context_mut().add_tailwind_class(content.to_string());
f.write_element(FormatElement::TailwindClass(index));
write!(f, quote);
return;
}

let content = string_literal.value().as_str();
let trimmed = content.trim();

// Whitespace-only → normalize to single space
if trimmed.is_empty() {
if !content.is_empty() {
write!(f, text(" "));
}
write!(f, quote);
return;
}

Expand All @@ -236,6 +262,7 @@ pub fn write_tailwind_string_literal<'a>(
if has_trailing_ws && !collapse.end {
write!(f, text(" "));
}
write!(f, quote);
}

/// Writes a template element (quasi) with Tailwind class sorting.
Expand Down
18 changes: 6 additions & 12 deletions crates/oxc_formatter/src/write/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1078,27 +1078,21 @@ impl<'a> FormatWrite<'a> for AstNode<'a, StringLiteral<'a>> {
// Check if we're in a Tailwind context via stack (O(1) lookup)
// This handles nested string literals inside JSXAttribute/CallExpression values
let tailwind_ctx = f.context().tailwind_context().copied().filter(|ctx| {
// Skip if context is disabled (e.g., inside nested non-Tailwind call expressions)
if ctx.disabled {
// Skip:
// - if context is disabled (e.g., inside nested non-Tailwind call expressions)
// - empty string literals (which have no classes to sort)
if ctx.disabled || self.value.is_empty() {
return false;
}

// No whitespace means only one class, so no need to sort
let content = f.source_text().text_for(self);
content.as_bytes().iter().any(|&b| b.is_ascii_whitespace())
});

if let Some(ctx) = tailwind_ctx {
// We're inside a Tailwind context - sort this string literal as Tailwind classes
let quote = if ctx.is_jsx {
f.options().jsx_quote_style.as_char()
} else {
f.options().quote_style.as_char()
};
let quote_str = if quote == '"' { "\"" } else { "'" };

write!(f, quote_str);
write_tailwind_string_literal(self, ctx.preserve_whitespace, f);
write!(f, quote_str);
write_tailwind_string_literal(self, ctx, f);
} else {
// Not in Tailwind context - use normal string literal formatting
let is_jsx = matches!(self.parent, AstNodes::JSXAttribute(_));
Expand Down
Loading