mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
## Summary
With `sortTailwindcss` enabled (and `preserveWhitespace` off),
formatting a JSX element whose Tailwind class string spans multiple
lines is not idempotent. It takes two runs to reach a stable output.
Input (`{"sortTailwindcss": {}}`):
```tsx
const A = (
<div
className="p-4
flex"
>
Hello
</div>
);
```
1st run:
```tsx
const A = (
<div
className="flex p-4"
>
Hello
</div>
);
```
2nd run:
```tsx
const A = <div className="flex p-4">Hello</div>;
```
### Cause
`FormatOpeningElement` decides its layout from the attribute's
**source** string value:
- `SingleStringAttribute` is used only if the value has no line break.
- Otherwise the attributes group is forced to expand if any string value
has a line break.
But for a Tailwind attribute without `preserveWhitespace`,
`write_tailwind_string_literal` collapses all whitespace (including line
breaks) into single spaces. So the printed value has no line break while
the layout was decided as if it had one. The next run sees a single-line
value and joins the element.
prettier-plugin-tailwindcss rewrites the attribute value in the AST
before printing, so Prettier makes the layout decision on the collapsed
value and gets the 2nd-run output in one pass.
### Fix
Decide the layout from whether the **printed** value has a line break. A
Tailwind attribute (per `is_tailwind_jsx_attribute`) with
`preserveWhitespace` off is treated as single-line. Nothing changes when
`sortTailwindcss` is off, with `preserveWhitespace: true`, or for
non-Tailwind attributes.
## Test
- Added 3 cases to `apps/oxfmt/test/api/sort_tailwindcss.test.ts`:
- multiline class string in a single-attribute and a multi-attribute
element: formats in one run, and a 2nd run doesn't change the output.
Fails without this fix.
- `preserveWhitespace: true`: still keeps line breaks and expands the
element (unchanged).
- multiline non-Tailwind attribute (`title`) next to a `className`:
still expands the element (unchanged).
- `apps/oxfmt`: `vitest run` → 553 passed
- `cargo test -p oxc_formatter`, `CARGO_BUILD_WARNINGS=deny cargo lint
-p oxc_formatter`, `cargo fmt --check`, `node --run fmt` pass
- I didn't run `conformance:prettier` (fixtures not downloaded locally).
It doesn't enable `sortTailwindcss`, so this code path behaves the same
as before there.
- Found while migrating a real Next.js app to oxfmt: 42 files needed a
second `oxfmt --write` run.
---------
Co-authored-by: leaysgur <6259812+leaysgur@users.noreply.github.com>
1478 lines
52 KiB
TypeScript
1478 lines
52 KiB
TypeScript
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join, relative } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import { format } from "../../dist/index.js";
|
|
|
|
describe("Tailwind CSS Sorting", () => {
|
|
it("should sort Tailwind classes when experimentalTailwindcss is enabled", async () => {
|
|
// Unsorted: p-4 comes before flex
|
|
const input = `const A = <div className="p-4 flex bg-red-500 text-white">Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// After sorting, flex should come before p-4 (display before spacing)
|
|
// The exact order of bg-red-500 and text-white may vary by Tailwind version
|
|
expect(result.code).toContain('className="flex');
|
|
expect(result.code).not.toContain('className="p-4 flex'); // p-4 should not be before flex
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should resolve relative tailwindConfig paths", async () => {
|
|
const cwd = process.cwd();
|
|
const dir = await mkdtemp(join(tmpdir(), "oxfmt-tailwind-"));
|
|
|
|
try {
|
|
await writeFile(
|
|
join(dir, "tailwind.config.js"),
|
|
"module.exports = { content: [], theme: { extend: {} }, plugins: [] };",
|
|
);
|
|
|
|
const input = `const A = <div className="p-4 flex">Hello</div>;`;
|
|
const result = await format("src/test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
config: join(relative(cwd, dir), "tailwind.config.js"),
|
|
},
|
|
});
|
|
|
|
expect(result.code).toContain('className="flex p-4"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
} finally {
|
|
await rm(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("should NOT sort Tailwind classes when experimentalTailwindcss is disabled (default)", async () => {
|
|
const input = `const A = <div className="p-4 flex bg-red-500 text-white">Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input);
|
|
|
|
// Original order should be preserved
|
|
expect(result.code).toContain('className="p-4 flex bg-red-500 text-white"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
// Regression test for https://github.com/oxc-project/oxc/pull/21919
|
|
it("should NOT sort Tailwind classes when sortTailwindcss is explicitly false", async () => {
|
|
const input = `const A = <div className="p-4 flex bg-red-500 text-white">Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, { sortTailwindcss: false });
|
|
|
|
expect(result.code).toContain('className="p-4 flex bg-red-500 text-white"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort multiple className attributes", async () => {
|
|
// Use classes that will definitely be reordered
|
|
const input = `
|
|
const A = (
|
|
<div className="p-4 flex">
|
|
<span className="p-2 inline">Title</span>
|
|
</div>
|
|
);`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Both className attributes should be sorted (display utilities before spacing)
|
|
expect(result.code).toContain('className="flex p-4"');
|
|
expect(result.code).toContain('className="inline p-2"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should handle class attribute (not just className)", async () => {
|
|
const input = `const A = <div class="p-4 flex">Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
expect(result.code).toContain('class="flex p-4"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should accept experimentalTailwindcss as object with options", async () => {
|
|
const input = `const A = <div className="p-4 flex">Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
preserveWhitespace: false,
|
|
preserveDuplicates: false,
|
|
},
|
|
});
|
|
|
|
// Should still sort when options object is provided
|
|
expect(result.code).toContain('className="flex p-4"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should respect attributes option for custom attributes", async () => {
|
|
// By default, only 'class' and 'className' are sorted
|
|
const input = `const A = <div myClassProp="p-4 flex">Hello</div>;`;
|
|
|
|
// Without attributes, custom attribute should NOT be sorted
|
|
const resultWithoutOption = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
expect(resultWithoutOption.code).toContain('myClassProp="p-4 flex"');
|
|
|
|
// With attributes including 'myClassProp', it SHOULD be sorted
|
|
const resultWithOption = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
attributes: ["myClassProp"],
|
|
},
|
|
});
|
|
expect(resultWithOption.code).toContain('myClassProp="flex p-4"');
|
|
expect(resultWithOption.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should respect functions option for custom functions", async () => {
|
|
// Test with clsx function call
|
|
const input = `const A = <div className={clsx("p-4 flex")}>Hello</div>;`;
|
|
|
|
// With functions including 'clsx', the string argument should be sorted
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["clsx"],
|
|
},
|
|
});
|
|
|
|
// The clsx argument should be sorted
|
|
expect(result.code).toContain('clsx("flex p-4")');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should handle multiple functions", async () => {
|
|
const input = `
|
|
const A = (
|
|
<div className={clsx("p-4 flex")}>
|
|
<span className={cva("p-2 inline")}>Title</span>
|
|
</div>
|
|
);`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["clsx", "cva"],
|
|
},
|
|
});
|
|
|
|
expect(result.code).toContain('clsx("flex p-4")');
|
|
expect(result.code).toContain('cva("inline p-2")');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort classes in member expression on tailwind function (clsx.foo(...))", async () => {
|
|
const input = `const A = <div className={clsx.foo("p-4 flex")}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["clsx"],
|
|
},
|
|
});
|
|
|
|
expect(result.code).toContain('clsx.foo("flex p-4")');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort classes in object member tailwind function (obj.clsx(...))", async () => {
|
|
const input = `const A = <div className={obj.clsx("p-4 flex")}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["obj"],
|
|
},
|
|
});
|
|
|
|
expect(result.code).toContain('obj.clsx("flex p-4")');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort classes in chained call (foo().clsx(...))", async () => {
|
|
const input = `const A = <div className={foo().clsx("p-4 flex")}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["foo"],
|
|
},
|
|
});
|
|
|
|
expect(result.code).toContain('foo().clsx("flex p-4")');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort classes in deeply nested member expression (a.b.c.clsx(...))", async () => {
|
|
const input = `const A = <div className={a.b.c.clsx("p-4 flex")}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["a"],
|
|
},
|
|
});
|
|
|
|
expect(result.code).toContain('a.b.c.clsx("flex p-4")');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort classes in computed member expression (obj[key](...))", async () => {
|
|
const input = `const A = <div className={obj[key]("p-4 flex")}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["obj"],
|
|
},
|
|
});
|
|
|
|
expect(result.code).toContain('obj[key]("flex p-4")');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should preserve whitespace when preserveWhitespace is true", async () => {
|
|
// Input with leading/trailing whitespace in class string
|
|
const input = `const A = <div className=" p-4 flex ">Hello</div>;`;
|
|
|
|
// Without preserveWhitespace, whitespace should be trimmed
|
|
const resultWithoutOption = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
expect(resultWithoutOption.code).toContain('className="flex p-4"');
|
|
|
|
// With preserveWhitespace: true, whitespace should be preserved
|
|
const resultWithOption = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
preserveWhitespace: true,
|
|
},
|
|
});
|
|
// Whitespace should be preserved around the sorted classes
|
|
expect(resultWithOption.code).toContain('className=" flex p-4 "');
|
|
expect(resultWithOption.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should remove duplicates by default", async () => {
|
|
// Input with duplicate class names
|
|
const input = `const A = <div className="flex p-4 flex p-4">Hello</div>;`;
|
|
|
|
// By default, duplicates should be removed
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Should only have one instance of each class
|
|
expect(result.code).toContain('className="flex p-4"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should preserve duplicates when preserveDuplicates is true", async () => {
|
|
// Input with duplicate class names
|
|
const input = `const A = <div className="flex p-4 flex p-4">Hello</div>;`;
|
|
|
|
// With preserveDuplicates: true, duplicates should be preserved
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
preserveDuplicates: true,
|
|
},
|
|
});
|
|
|
|
// Duplicates should be preserved (sorted but kept)
|
|
expect(result.code).toContain('className="flex flex p-4 p-4"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
// Template literal tests for ignoreFirst/ignoreLast behavior
|
|
it("should handle template literal with expressions - no spacing around expression", async () => {
|
|
// No space between classes and expressions - adjacent classes should NOT be sorted
|
|
// "p-4" is last in first quasi (no trailing space) -> ignored
|
|
// "flex" is first in second quasi (no leading space) -> ignored
|
|
const input = `const A = <div className={\`p-4\${x}flex\`}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Both "p-4" and "flex" are adjacent to expressions - should remain untouched
|
|
expect(result.code).toContain("`p-4${x}flex`");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should handle template literal with expressions - with spacing", async () => {
|
|
// Spaces around classes - classes should be sorted
|
|
const input = `const A = <div className={\`p-4 flex \${x} m-2 inline\`}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// First quasi "p-4 flex " has trailing space, so all classes get sorted: "flex p-4 "
|
|
// Second quasi " m-2 inline" - sorted per Tailwind order
|
|
expect(result.code).toContain("flex p-4");
|
|
expect(result.code).toContain("m-2 inline");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should handle template literal with partial spacing", async () => {
|
|
// First quasi has trailing space, second doesn't start with space
|
|
// "p-4 flex " -> all sortable
|
|
// "m-2 inline" -> "m-2" is first without leading space, should be ignored
|
|
const input = `const A = <div className={\`p-4 flex \${x}m-2 inline\`}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// First quasi should be sorted: "flex p-4 "
|
|
expect(result.code).toContain("flex p-4");
|
|
// "m-2" is adjacent to ${x} (no space) - should stay first, "inline" can be sorted relative to rest
|
|
// The expected result is: `flex p-4 ${x}m-2 inline`
|
|
expect(result.code).toContain("${x}m-2");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort classes after ignored first class", async () => {
|
|
// When expression is directly followed by a class (no space), that class is ignored
|
|
// But subsequent classes should still be sorted with proper spacing
|
|
const input = "const A = <div className={`flex ${variant}items-center p-4`}>Hello</div>;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// "items-center" is ignored (touching expression), "p-4" is sorted
|
|
// Space should be preserved between ignored class and sorted content
|
|
expect(result.code).toContain("${variant}items-center p-4");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort classes before ignored last class", async () => {
|
|
// When a class is directly followed by expression (no space), that class is ignored
|
|
const input = "const A = <div className={`flex p-4${variant} items-center`}>Hello</div>;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// "p-4" is ignored (touching expression), "flex" is sorted
|
|
// Space should be preserved between sorted content and ignored class
|
|
expect(result.code).toContain("flex p-4${variant}");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should handle simple template literal without expressions", async () => {
|
|
// Template literal without expressions should be treated like string literal
|
|
const input = `const A = <div className={\`p-4 flex\`}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
expect(result.code).toContain("`flex p-4`");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should handle template literal with functions", async () => {
|
|
const input = `const A = <div className={clsx(\`p-4 flex \${x} m-2 inline\`)}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["clsx"],
|
|
},
|
|
});
|
|
|
|
// Classes should be sorted within each quasi
|
|
expect(result.code).toContain("flex p-4");
|
|
expect(result.code).toContain("m-2 inline");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should handle template literal with line breaks", async () => {
|
|
// Template literal with line breaks between classes
|
|
const input = `const A = <div className={\`
|
|
p-4 flex
|
|
\${x}
|
|
m-2 inline
|
|
\`}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Classes should be sorted within each quasi, line breaks preserved
|
|
expect(result.code).toContain("flex p-4");
|
|
expect(result.code).toContain("m-2 inline");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should preserve spaces between expressions", async () => {
|
|
// Template literal with only spaces between expressions should not lose them
|
|
const input = "const A = <div className={`${a} ${b} ${c}`}>Hello</div>;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Spaces between expressions should be preserved
|
|
expect(result.code).toContain("${a} ${b} ${c}");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should normalize multiple spaces between expressions to single space", async () => {
|
|
// Template literal with only multiple spaces between expressions
|
|
const input = "const A = <div className={`${a} ${b} ${c}`}>Hello</div>;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Multiple spaces should be normalized to single space
|
|
expect(result.code).toContain("${a} ${b} ${c}");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should handle template literal with only spaces (no classes)", async () => {
|
|
// Template literal containing only spaces - normalized to single space
|
|
const input = "const A = <div className={` `}>Hello</div>;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Multiple spaces normalized to single space (like Prettier)
|
|
expect(result.code).toContain("className={` `}");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should normalize multiple spaces around expressions to single space", async () => {
|
|
// Template literal with multiple spaces around expression
|
|
const input =
|
|
"const A = <div className={`flex items-center ${variant} bg-blue-500 p-4`}>Hello</div>;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Multiple spaces should be collapsed to single space
|
|
expect(result.code).toContain("flex items-center ${variant} bg-blue-500 p-4");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should collapse whitespace in template literal with line breaks when preserveWhitespace is false", async () => {
|
|
// Template literal with line breaks - whitespace should be collapsed
|
|
const input = `const A = <div className={\`
|
|
p-4 flex
|
|
\${x}
|
|
m-2 inline
|
|
\`}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
preserveWhitespace: false,
|
|
},
|
|
});
|
|
|
|
// Leading/trailing whitespace in first/last quasi should be collapsed
|
|
// First quasi starts with newline+spaces, last quasi ends with newline+spaces
|
|
expect(result.code).toContain("`flex p-4");
|
|
expect(result.code).toContain("m-2 inline`");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should preserve whitespace in template literal when preserveWhitespace is true", async () => {
|
|
// Template literal with leading/trailing whitespace in first and last quasis
|
|
const input = `const A = <div className={\` p-4 flex \${x} m-2 inline \`}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
preserveWhitespace: true,
|
|
},
|
|
});
|
|
|
|
// Whitespace should be preserved - first quasi keeps leading spaces, last keeps trailing
|
|
expect(result.code).toContain("` flex p-4");
|
|
expect(result.code).toContain("m-2 inline `");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
// Regression test for https://github.com/oxc-project/oxc/issues/24464
|
|
it("should not split a class glued to an expression when preserveWhitespace is true", async () => {
|
|
let input = "const A = <div className={`text-white flex bg-${color}`}>Hello</div>;";
|
|
let result = await format("test.tsx", input, { sortTailwindcss: { preserveWhitespace: true } });
|
|
|
|
// `bg-` touches ${color} and must stay glued to it; other classes are still sorted
|
|
expect(result.code).toContain("`flex text-white bg-${color}`");
|
|
expect(result.errors).toStrictEqual([]);
|
|
|
|
input =
|
|
"const A = <div className={`mb-4 flex items-center rounded-full bg-${color}/10`}>Hello</div>;";
|
|
result = await format("test.tsx", input, { sortTailwindcss: { preserveWhitespace: true } });
|
|
|
|
// `bg-` and the `/10` modifier must stay glued to ${color}
|
|
expect(result.code).toContain("bg-${color}/10`");
|
|
expect(result.code).not.toContain("bg- ");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should collapse newlines to single space when preserveWhitespace is false (default)", async () => {
|
|
const input = `<div className={\`flex
|
|
items-center
|
|
bg-blue-500
|
|
p-4
|
|
text-white
|
|
\${isActive ? \`font-bold
|
|
shadow-lg\` : "font-normal"}\`} />;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Newlines should be collapsed to single space, classes sorted
|
|
expect(result.code).toMatchInlineSnapshot(`
|
|
"<div
|
|
className={\`flex items-center bg-blue-500 p-4 text-white \${
|
|
isActive ? \`font-bold shadow-lg\` : "font-normal"
|
|
}\`}
|
|
/>;
|
|
"
|
|
`);
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should preserve newlines when preserveWhitespace is true", async () => {
|
|
const input = `<div className={\`flex
|
|
items-center
|
|
bg-blue-500
|
|
p-4
|
|
text-white
|
|
\${isActive ? \`font-bold
|
|
shadow-lg\` : "font-normal"}\`} />;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
preserveWhitespace: true,
|
|
},
|
|
});
|
|
|
|
// Newlines should be preserved
|
|
expect(result.code).toMatchInlineSnapshot(`
|
|
"<div className={\`flex
|
|
items-center
|
|
bg-blue-500
|
|
p-4
|
|
text-white
|
|
\${isActive ? \`font-bold
|
|
shadow-lg\` : "font-normal"}\`} />;
|
|
"
|
|
`);
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should not break JSX element for multiline class string that gets collapsed", async () => {
|
|
const input = `const A = (
|
|
<div
|
|
className="p-4
|
|
flex"
|
|
>
|
|
Hello
|
|
</div>
|
|
);
|
|
const B = (
|
|
<div
|
|
id="b"
|
|
className="p-4
|
|
flex"
|
|
>
|
|
Hello
|
|
</div>
|
|
);
|
|
`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// The value is collapsed into one line, so the element should not be expanded
|
|
// as if the value were still multiline.
|
|
expect(result.code).toMatchInlineSnapshot(`
|
|
"const A = <div className="flex p-4">Hello</div>;
|
|
const B = (
|
|
<div id="b" className="flex p-4">
|
|
Hello
|
|
</div>
|
|
);
|
|
"
|
|
`);
|
|
expect(result.errors).toStrictEqual([]);
|
|
|
|
// Formatting the output again should not change it.
|
|
const second = await format("test.tsx", result.code, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
expect(second.code).toBe(result.code);
|
|
});
|
|
|
|
it("should still break JSX element for multiline class string when preserveWhitespace is true", async () => {
|
|
const input = `const A = (
|
|
<div
|
|
className="flex
|
|
p-4"
|
|
>
|
|
Hello
|
|
</div>
|
|
);
|
|
`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: { preserveWhitespace: true },
|
|
});
|
|
|
|
expect(result.code).toBe(input);
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should still break JSX element for multiline non-Tailwind string attribute", async () => {
|
|
const input = `const A = (
|
|
<div
|
|
title="first
|
|
second"
|
|
className="p-4 flex"
|
|
>
|
|
Hello
|
|
</div>
|
|
);
|
|
`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
expect(result.code).toMatchInlineSnapshot(`
|
|
"const A = (
|
|
<div
|
|
title="first
|
|
second"
|
|
className="flex p-4"
|
|
>
|
|
Hello
|
|
</div>
|
|
);
|
|
"
|
|
`);
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
// Tests for can_collapse_whitespace in template literal expressions
|
|
// These test the whitespace preservation logic based on adjacent quasi whitespace
|
|
|
|
it("should collapse leading space when quasi before ends with whitespace", async () => {
|
|
// Quasi "header " ends with space, so leading space in string can be collapsed
|
|
const input = "const A = <div className={`header ${isExtendable ? ' active' : ''}`} />;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Leading space should be trimmed (quasi already provides separation)
|
|
expect(result.code).toContain('`header ${isExtendable ? "active" : ""}`');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should preserve leading space when quasi before has no trailing whitespace", async () => {
|
|
// Quasi "header" has NO trailing space, so leading space in string must be preserved
|
|
const input = "const A = <div className={`header${isExtendable ? ' active' : ''}`} />;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Leading space must be preserved (no separation from quasi)
|
|
expect(result.code).toContain('`header${isExtendable ? " active" : ""}`');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should collapse trailing space when quasi after starts with whitespace", async () => {
|
|
// Quasi " suffix" starts with space, so trailing space in string can be collapsed
|
|
const input = "const A = <div className={`${condition ? 'active ' : ''} suffix`} />;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Trailing space should be trimmed (quasi already provides separation)
|
|
expect(result.code).toContain('${condition ? "active" : ""} suffix');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should preserve trailing space when quasi after has no leading whitespace", async () => {
|
|
// Quasi "suffix" has NO leading space, so trailing space in string must be preserved
|
|
const input = "const A = <div className={`${condition ? 'active ' : ''}suffix`} />;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Trailing space must be preserved (no separation to quasi)
|
|
expect(result.code).toContain('${condition ? "active " : ""}suffix');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should preserve both leading and trailing space when surrounded by non-whitespace quasis", async () => {
|
|
// String " middle " is between quasis without whitespace on either side
|
|
const input = "const A = <div className={`prefix${condition ? ' middle ' : ''}suffix`} />;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Both spaces must be preserved
|
|
expect(result.code).toContain('${condition ? " middle " : ""}');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
// Tests for nested expressions inside template literals (PR #396 fixes)
|
|
it("should not trim whitespace inside nested ternary string literals", async () => {
|
|
// The leading space in ' header-extendable' should NOT be trimmed
|
|
// because the quasi before the expression doesn't end with whitespace
|
|
const input =
|
|
"const A = <div className={`header${isExtendable ? ' header-extendable' : ''}`} />;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Space should be preserved in the ternary string (formatter uses double quotes)
|
|
expect(result.code).toContain('`header${isExtendable ? " header-extendable" : ""}`');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should not trim whitespace inside concat expressions", async () => {
|
|
// Spaces inside concat expressions should be preserved
|
|
const input = "const A = <div className={a + ' p-4 ' + b} />;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Spaces in the middle of concat should be preserved (formatter uses double quotes)
|
|
expect(result.code).toContain('a + " p-4 " + b');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
// https://github.com/oxc-project/oxc/issues/20397
|
|
it("should preserve trailing space in ternary inside binary concat", async () => {
|
|
const input = `const A = <div className={"h-fit m-1 w-full " + (flag1 ? "block " : "hidden ") + (flag2 ? "p-2" : "p-4")} />;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
expect(result.code).toContain('"m-1 h-fit w-full "');
|
|
expect(result.code).toContain('"block "');
|
|
expect(result.code).toContain('"hidden "');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
// Tests for template literals in binary expressions
|
|
it("should sort template literal on right side of binary expression", async () => {
|
|
const input = "const A = <div className={a + ` p-4 flex `} />;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Template literal should be sorted, spaces preserved at boundaries
|
|
expect(result.code).toContain("a + ` flex p-4`");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort template literal on left side of binary expression", async () => {
|
|
const input = "const A = <div className={` p-4 flex ` + b} />;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Template literal should be sorted, spaces preserved at boundaries
|
|
expect(result.code).toContain("`flex p-4 ` + b");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort template literal in middle of binary expression", async () => {
|
|
const input = "const A = <div className={a + ` p-4 flex ` + b} />;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Template literal should be sorted, spaces preserved at boundaries
|
|
expect(result.code).toContain("a + ` flex p-4 ` + b");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort template literal with expressions in binary expression", async () => {
|
|
const input = "const A = <div className={a + ` p-4 ${x} flex ` + b} />;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Classes should be sorted, expression preserved
|
|
expect(result.code).toContain("a + ` p-4 ${x} flex ` + b");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should not trim whitespace in nested ternary with leading space only", async () => {
|
|
// Issue #337: leading space removed incorrectly
|
|
const input =
|
|
"const A = <div className={`MuiApi-item-root${isExtendable ? ' MuiApi-item-header-extendable' : ''}`} />;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Leading space must be preserved (formatter uses double quotes)
|
|
expect(result.code).toContain(
|
|
'`MuiApi-item-root${isExtendable ? " MuiApi-item-header-extendable" : ""}`',
|
|
);
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
// Tests for nested template literals (inside ternary, concat, etc.)
|
|
it("should sort template literal inside ternary expression", async () => {
|
|
// Template literal nested inside a ternary should be sorted
|
|
const input = "const A = <div className={condition ? `p-4 flex` : `m-2 grid`} />;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Both template literals should be sorted
|
|
expect(result.code).toContain("`flex p-4`");
|
|
expect(result.code).toContain("`m-2 grid`"); // m-2 grid is already in correct Tailwind order
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort template literal with expressions inside ternary", async () => {
|
|
// Template literal with expressions, nested inside ternary
|
|
const input = "const A = <div className={condition ? `p-4 flex ${x} m-2 inline` : `grid`} />;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Classes should be sorted within quasis
|
|
expect(result.code).toContain("flex p-4");
|
|
expect(result.code).toContain("m-2 inline");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort template literal inside logical expression", async () => {
|
|
// Template literal nested inside logical OR
|
|
const input = "const A = <div className={variant || `p-4 flex`} />;";
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
expect(result.code).toContain("`flex p-4`");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
// Tests for nested call expressions - strings inside non-Tailwind calls should NOT be sorted
|
|
// Issue: https://github.com/tailwindlabs/prettier-plugin-tailwindcss/issues/426
|
|
it("should NOT sort strings inside nested non-Tailwind call expressions", async () => {
|
|
// The "\n" inside value.includes() should NOT be treated as a Tailwind class
|
|
const input = `const A = <div className={classNames(
|
|
"bg-red-500",
|
|
value.includes("\\n") ? "pt-2" : "pt-1"
|
|
)} />;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["classNames"],
|
|
},
|
|
});
|
|
|
|
// The "\n" should remain as "\n", not be corrupted
|
|
expect(result.code).toContain('value.includes("\\n")');
|
|
// Direct Tailwind class strings should still be sorted (single classes, no sorting needed here)
|
|
expect(result.code).toContain('"bg-red-500"');
|
|
expect(result.code).toContain('"pt-2"');
|
|
expect(result.code).toContain('"pt-1"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should NOT sort strings inside deeply nested call expressions", async () => {
|
|
// Multiple levels of nested calls - strings in inner calls should be preserved
|
|
const input = `const A = <div className={clsx(
|
|
"flex p-4",
|
|
arr.filter(x => x.startsWith("prefix")).join(" ")
|
|
)} />;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["clsx"],
|
|
},
|
|
});
|
|
|
|
// Direct Tailwind classes should be sorted
|
|
expect(result.code).toContain('"flex p-4"');
|
|
// Strings inside nested calls should be preserved
|
|
expect(result.code).toContain('startsWith("prefix")');
|
|
expect(result.code).toContain('join(" ")');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort Tailwind classes but preserve strings in member expression calls", async () => {
|
|
// str.split(" ") - the " " should not be sorted as Tailwind
|
|
const input = `const A = <div className={clsx("p-4 flex", input.split(" ").map(x => x))} />;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["clsx"],
|
|
},
|
|
});
|
|
|
|
// Direct Tailwind classes should be sorted
|
|
expect(result.code).toContain('"flex p-4"');
|
|
// The " " in split should be preserved
|
|
expect(result.code).toContain('split(" ")');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should preserve escape sequences in nested call expressions", async () => {
|
|
// Various escape sequences should be preserved in nested calls
|
|
const input = `const A = <div className={clsx(
|
|
"flex",
|
|
str.includes("\\t") && "p-4",
|
|
str.match(/\\s/) && "m-2"
|
|
)} />;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["clsx"],
|
|
},
|
|
});
|
|
|
|
// Escape sequences should be preserved
|
|
expect(result.code).toContain('includes("\\t")');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should handle empty class string", async () => {
|
|
const input = `const A = <div className="">Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Empty string should remain empty
|
|
expect(result.code).toContain('className=""');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should handle whitespace-only class string", async () => {
|
|
const input = `const A = <div className=" ">Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Whitespace-only should be normalized to single space
|
|
expect(result.code).toContain('className=" "');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should handle deeply nested template literals (3 levels)", async () => {
|
|
const input = `const A = <div className={\`p-4 flex \${a ? \`m-2 grid \${b ? \`inline-block\` : ""}\` : ""}\`}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// All levels should be sorted
|
|
expect(result.code).toContain("flex p-4");
|
|
expect(result.code).toContain("m-2 grid");
|
|
expect(result.code).toContain("inline-block");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should handle template literal inside function inside template literal", async () => {
|
|
const input = `const A = <div className={\`p-4 flex \${clsx(\`m-2 grid\`)}\`}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["clsx"],
|
|
},
|
|
});
|
|
|
|
// Both template literals should be sorted
|
|
expect(result.code).toContain("flex p-4");
|
|
expect(result.code).toContain("m-2 grid");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should handle multiple function calls with template literals", async () => {
|
|
const input = `const A = <div className={clsx(\`p-4 flex\`, cva(\`m-2 grid\`))}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["clsx", "cva"],
|
|
},
|
|
});
|
|
|
|
// Both template literals should be sorted
|
|
expect(result.code).toContain("flex p-4");
|
|
expect(result.code).toContain("m-2 grid");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should handle array of template literals in function call", async () => {
|
|
const input = `const A = <div className={clsx([\`p-4 flex\`, \`m-2 grid\`])}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["clsx"],
|
|
},
|
|
});
|
|
|
|
// Both template literals should be sorted
|
|
expect(result.code).toContain("flex p-4");
|
|
expect(result.code).toContain("m-2 grid");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should handle object with template literal values", async () => {
|
|
const input = `const A = <div className={clsx({[\`p-4 flex\`]: true, [\`m-2 grid\`]: false})}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["clsx"],
|
|
},
|
|
});
|
|
|
|
// Template literals should be sorted
|
|
expect(result.code).toContain("flex p-4");
|
|
expect(result.code).toContain("m-2 grid");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
// Issue: https://github.com/oxc-project/oxc/issues/18712
|
|
it("should sort classes in object property keys (string literals)", async () => {
|
|
// Object keys with class names should be sorted like other strings
|
|
const input = `const A = <div className={cn({ 'p-[2px] elevation-elevated-selected': true })}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["cn"],
|
|
},
|
|
});
|
|
|
|
// Classes in object keys should be sorted
|
|
expect(result.code).toContain("elevation-elevated-selected p-[2px]");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort classes in multiple object property keys", async () => {
|
|
const input = `const A = <div className={clsx({
|
|
'p-4 flex': condition1,
|
|
'm-2 grid': condition2,
|
|
'text-white bg-red-500': condition3
|
|
})}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["clsx"],
|
|
},
|
|
});
|
|
|
|
// All object key class strings should be sorted
|
|
expect(result.code).toContain("flex p-4");
|
|
expect(result.code).toContain("m-2 grid");
|
|
expect(result.code).toContain("bg-red-500 text-white");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should NOT sort strings inside non-Tailwind function calls in object keys", async () => {
|
|
// Strings in nested function calls should not be sorted
|
|
const input = `const A = <div className={clsx({
|
|
'p-4 flex': value.includes(" "),
|
|
'error-class': true
|
|
})}>Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["clsx"],
|
|
},
|
|
});
|
|
|
|
// Object key classes should be sorted, but the "\n" in includes() should be preserved
|
|
expect(result.code).toContain("flex p-4");
|
|
expect(result.code).toContain('includes(" ")');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("Tailwind CSS Sorting (Non-JS Files)", () => {
|
|
it("should sort Tailwind classes in HTML files", async () => {
|
|
const input = `<div class="p-4 flex bg-red-500 text-white">Hello</div>`;
|
|
|
|
const result = await format("test.html", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// After sorting, flex should come before p-4 (display before spacing)
|
|
expect(result.code).toContain('class="flex');
|
|
expect(result.code).not.toContain('class="p-4 flex');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should NOT sort Tailwind classes in HTML when experimentalTailwindcss is disabled", async () => {
|
|
const input = `<div class="p-4 flex bg-red-500 text-white">Hello</div>`;
|
|
|
|
const result = await format("test.html", input);
|
|
|
|
// Original order should be preserved
|
|
expect(result.code).toContain('class="p-4 flex bg-red-500 text-white"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort Tailwind classes in Vue SFC files", async () => {
|
|
const input = `<template>
|
|
<div class="p-4 flex bg-red-500 text-white">Hello</div>
|
|
</template>`;
|
|
|
|
const result = await format("test.vue", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// After sorting, flex should come before p-4
|
|
expect(result.code).toContain('class="flex');
|
|
expect(result.code).not.toContain('class="p-4 flex');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort multiple class attributes in HTML", async () => {
|
|
const input = `<div class="p-4 flex">
|
|
<span class="text-white bg-red-500">Title</span>
|
|
</div>`;
|
|
|
|
const result = await format("test.html", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Both class attributes should be sorted
|
|
expect(result.code).toContain('class="flex p-4"');
|
|
expect(result.code).toContain('class="bg-red-500 text-white"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should respect attributes option in HTML", async () => {
|
|
const input = `<div class="p-4 flex" data-classes="text-white bg-red-500">Hello</div>`;
|
|
|
|
// Without attributes option, only class should be sorted
|
|
const resultWithoutOption = await format("test.html", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
expect(resultWithoutOption.code).toContain('class="flex p-4"');
|
|
expect(resultWithoutOption.code).toContain('data-classes="text-white bg-red-500"'); // Not sorted
|
|
|
|
// With attributes option, data-classes should also be sorted
|
|
const resultWithOption = await format("test.html", input, {
|
|
experimentalTailwindcss: {
|
|
attributes: ["data-classes"],
|
|
},
|
|
});
|
|
expect(resultWithOption.code).toContain('class="flex p-4"');
|
|
expect(resultWithOption.code).toContain('data-classes="bg-red-500 text-white"'); // Sorted
|
|
expect(resultWithOption.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should respect functions option in Vue SFC", async () => {
|
|
const input = `<template>
|
|
<div :class="clsx('p-4 flex')">Hello</div>
|
|
</template>`;
|
|
|
|
// With functions option, clsx argument should be sorted
|
|
const result = await format("test.vue", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["clsx"],
|
|
},
|
|
});
|
|
|
|
expect(result.code).toContain("clsx('flex p-4')");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should remove duplicates in HTML by default", async () => {
|
|
const input = `<div class="flex p-4 flex p-4">Hello</div>`;
|
|
|
|
const result = await format("test.html", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// Duplicates should be removed
|
|
expect(result.code).toContain('class="flex p-4"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should preserve duplicates in HTML when preserveDuplicates is true", async () => {
|
|
const input = `<div class="flex p-4 flex p-4">Hello</div>`;
|
|
|
|
const result = await format("test.html", input, {
|
|
experimentalTailwindcss: {
|
|
preserveDuplicates: true,
|
|
},
|
|
});
|
|
|
|
// Duplicates should be preserved
|
|
expect(result.code).toContain('class="flex flex p-4 p-4"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("Tailwind CSS Sorting with `experimentalSortImports` enabled", () => {
|
|
it("should sort Tailwind classes in default options", async () => {
|
|
const input = `const A = <div className="p-4 flex bg-red-500 text-white">Hello</div>;`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// After sorting, flex should come before p-4 (display before spacing)
|
|
// The exact order of bg-red-500 and text-white may vary by Tailwind version
|
|
expect(result.code).toContain('className="flex');
|
|
expect(result.code).not.toContain('className="p-4 flex'); // p-4 should not be before flex
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should preserve whitespace when preserveWhitespace is true", async () => {
|
|
// Input with leading/trailing whitespace in class string
|
|
const input = `const A = <div className=" p-4 flex ">Hello</div>;`;
|
|
|
|
// Without preserveWhitespace, whitespace should be trimmed
|
|
const resultWithoutOption = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {},
|
|
experimentalSortImports: {},
|
|
});
|
|
expect(resultWithoutOption.code).toContain('className="flex p-4"');
|
|
|
|
// With preserveWhitespace: true, whitespace should be preserved
|
|
const resultWithOption = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
preserveWhitespace: true,
|
|
},
|
|
experimentalSortImports: {},
|
|
});
|
|
// Whitespace should be preserved around the sorted classes
|
|
expect(resultWithOption.code).toContain('className=" flex p-4 "');
|
|
expect(resultWithOption.errors).toStrictEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("Tailwind CSS Sorting works with other options", () => {
|
|
it("should keep quotes with `singleQuote: true`", async () => {
|
|
const input = `
|
|
<div className={clsx('text-md before:content-["hello"]')}>Hello</div>;
|
|
<div className={clsx("text-md before:content-['hello']")}>Hello</div>;
|
|
<div className={showLandingPage ? "container pb-6" : 'hidden'}>title</div>
|
|
`;
|
|
|
|
const result = await format("test.tsx", input, {
|
|
experimentalTailwindcss: {
|
|
functions: ["clsx"],
|
|
},
|
|
singleQuote: true,
|
|
});
|
|
|
|
expect(result.code).toMatchInlineSnapshot(`
|
|
"<div className={clsx('text-md before:content-["hello"]')}>Hello</div>;
|
|
<div className={clsx("text-md before:content-['hello']")}>Hello</div>;
|
|
<div className={showLandingPage ? 'container pb-6' : 'hidden'}>title</div>;
|
|
"
|
|
`);
|
|
});
|
|
|
|
it("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>;
|
|
<div className={showLandingPage ? "container pb-6" : 'hidden'}>title</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>;
|
|
<div className={showLandingPage ? "container pb-6" : "hidden"}>title</div>;
|
|
"
|
|
`);
|
|
});
|
|
|
|
it("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>;
|
|
"
|
|
`);
|
|
});
|
|
});
|
|
|
|
describe("Tailwind CSS Sorting in Embedded HTML (Tagged Template Literals)", () => {
|
|
it("should sort Tailwind classes in html tagged template literal", async () => {
|
|
const input = `const view = html\`<div class="p-4 flex bg-red-500">Hello</div>\`;`;
|
|
|
|
const result = await format("test.ts", input, {
|
|
experimentalTailwindcss: {},
|
|
});
|
|
|
|
// After sorting, flex should come before p-4 (display before spacing)
|
|
expect(result.code).toContain('class="flex');
|
|
expect(result.code).not.toContain('class="p-4 flex');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("Tailwind CSS Sorting in CSS (@apply)", () => {
|
|
it("should sort @apply classes in CSS files", async () => {
|
|
const input = `.btn { @apply p-4 flex; }`;
|
|
|
|
const result = await format("test.css", input, {
|
|
sortTailwindcss: {},
|
|
});
|
|
|
|
expect(result.code).toContain("@apply flex p-4;");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
// The cases below mirror prettier-plugin-tailwindcss's `transformCss`;
|
|
// every expected output was verified against prettier@3.8.3 + the plugin.
|
|
it("should keep a !important tail unsorted", async () => {
|
|
const input = `.y { @apply p-4 flex !important; }`;
|
|
|
|
const result = await format("test.css", input, { sortTailwindcss: {} });
|
|
|
|
expect(result.code).toContain("@apply flex p-4 !important;");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should keep a SCSS #{!important} interpolation tail unsorted", async () => {
|
|
const input = `.s { @apply p-4 flex #{!important}; }`;
|
|
|
|
const result = await format("test.scss", input, { sortTailwindcss: {} });
|
|
|
|
expect(result.code).toContain("@apply flex p-4 #{!important};");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it('should sort inside a Less ~"..." escaped-string wrapper', async () => {
|
|
const input = `.z { @apply ~"p-4 flex"; }`;
|
|
|
|
const result = await format("test.less", input, { sortTailwindcss: {} });
|
|
|
|
expect(result.code).toContain('@apply ~"flex p-4";');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should dedup and collapse whitespace via the sorter", async () => {
|
|
const input = `.d { @apply p-4 flex p-4; }`;
|
|
|
|
const result = await format("test.css", input, { sortTailwindcss: {} });
|
|
|
|
expect(result.code).toContain("@apply flex p-4;");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort @apply inside css-in-js template literals", async () => {
|
|
const input = ["const A = styled.div`", " @apply p-4 flex;", " color: red;", "`;"].join("\n");
|
|
|
|
const result = await format("test.ts", input, { sortTailwindcss: {} });
|
|
|
|
expect(result.code).toContain("@apply flex p-4;");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort @apply in fenced css blocks in JSDoc", async () => {
|
|
const input = [
|
|
"/**",
|
|
" * ```css",
|
|
" * .c { @apply p-4 flex; }",
|
|
" * ```",
|
|
" */",
|
|
"export const C = 1;",
|
|
].join("\n");
|
|
|
|
const result = await format("test.ts", input, { sortTailwindcss: {}, jsdoc: {} });
|
|
|
|
expect(result.code).toContain("@apply flex p-4;");
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
it("should sort css-in-js @apply and JSX className in the same file", async () => {
|
|
const input = [
|
|
'const A = <div className="p-4 flex" />;',
|
|
"const B = styled.div`",
|
|
" @apply p-4 flex;",
|
|
"`;",
|
|
'const C = <div className="text-sm grid" />;',
|
|
].join("\n");
|
|
|
|
const result = await format("test.tsx", input, { sortTailwindcss: {} });
|
|
|
|
expect(result.code).toContain('className="flex p-4"');
|
|
expect(result.code).toContain("@apply flex p-4;");
|
|
expect(result.code).toContain('className="grid text-sm"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
// The table cell prints standalone, it keeps the classes but unsorted for now
|
|
it("should keep JSX className in a jest each table", async () => {
|
|
const input = [
|
|
"test.each`",
|
|
" a | b",
|
|
' ${<div className="p-4 flex" />} | ${1}',
|
|
"`('x', () => {});",
|
|
].join("\n");
|
|
|
|
const result = await format("test.tsx", input, { sortTailwindcss: {} });
|
|
|
|
expect(result.code).toContain('className="p-4 flex"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
|
|
// A JSDoc js fence formats as a nested root on the host's session,
|
|
// it must not take the host's classes.
|
|
it("should keep JSX className sorted next to a JSDoc js fence", async () => {
|
|
const input = [
|
|
'const A = <div className="p-4 flex" />;',
|
|
"/**",
|
|
" * ```js",
|
|
" * foo( 1 )",
|
|
" * ```",
|
|
" */",
|
|
'export const B = <div className="text-sm grid" />;',
|
|
].join("\n");
|
|
|
|
const result = await format("test.tsx", input, { sortTailwindcss: {}, jsdoc: {} });
|
|
|
|
expect(result.code).toContain('className="flex p-4"');
|
|
expect(result.code).toContain("foo(1);");
|
|
expect(result.code).toContain('className="grid text-sm"');
|
|
expect(result.errors).toStrictEqual([]);
|
|
});
|
|
});
|