diff --git a/package.json b/package.json
index cf3eaec..1645a67 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@aet/tailwind",
- "version": "0.0.1-beta.33",
+ "version": "0.0.1-beta.34",
"license": "MIT",
"scripts": {
"build": "./scripts/index.ts",
@@ -37,6 +37,7 @@
"postcss-nested": "^6.0.1",
"prettier": "^3.3.2",
"tailwindcss": "^3.4.4",
+ "tslib": "^2.6.3",
"tsup": "^8.1.0",
"typescript": "^5.5.3",
"vite": "^5.3.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4177272..c40c06c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -96,6 +96,9 @@ importers:
tailwindcss:
specifier: ^3.4.4
version: 3.4.4
+ tslib:
+ specifier: ^2.6.3
+ version: 2.6.3
tsup:
specifier: ^8.1.0
version: 8.1.0(postcss@8.4.39)(typescript@5.5.3)
diff --git a/src/__tests__/__snapshots__/attr.test.ts.snap b/src/__tests__/__snapshots__/attr.test.ts.snap
new file mode 100644
index 0000000..73ca4e2
--- /dev/null
+++ b/src/__tests__/__snapshots__/attr.test.ts.snap
@@ -0,0 +1,46 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`attr > supports conditional expression in "css" attribute 1`] = `
+"// src/.temp-attr/index.tsx
+import { jsx } from "react/jsx-runtime";
+function Hello({
+ isCenter
+}) {
+ return /* @__PURE__ */ jsx("div", { className: isCenter ? "tw-gqn2k6" : void 0, children: "Hello, world!" });
+}
+export {
+ Hello
+};
+"
+`;
+
+exports[`attr > supports conditional expression in "css" attribute 2`] = `
+"/* babel-tailwind:/Users/aet/Documents/Git/babel-tailwind/src/.temp-attr/index.css */
+.tw-gqn2k6 {
+ text-align: center;
+}
+"
+`;
+
+exports[`attr > supports grouped array css attribute 1`] = `
+"// src/.temp-attr/index.tsx
+import { jsx } from "react/jsx-runtime";
+function Hello() {
+ return /* @__PURE__ */ jsx("div", { className: "tw-gqn2k6 tw-1qtvvjy", children: "Hello, world!" });
+}
+export {
+ Hello
+};
+"
+`;
+
+exports[`attr > supports grouped array css attribute 2`] = `
+"/* babel-tailwind:/Users/aet/Documents/Git/babel-tailwind/src/.temp-attr/index.css */
+.tw-gqn2k6 {
+ text-align: center;
+}
+.tw-1qtvvjy:hover {
+ font-weight: 600;
+}
+"
+`;
diff --git a/src/__tests__/__snapshots__/options.test.ts.snap b/src/__tests__/__snapshots__/options.test.ts.snap
new file mode 100644
index 0000000..f21ba7d
--- /dev/null
+++ b/src/__tests__/__snapshots__/options.test.ts.snap
@@ -0,0 +1,21 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`options > supports custom jsxAttributeName 1`] = `
+"// src/.temp-options/index.tsx
+import { jsx } from "react/jsx-runtime";
+function Hello() {
+ return /* @__PURE__ */ jsx("div", { className: "tw-gqn2k6", children: "Hello, world!" });
+}
+export {
+ Hello
+};
+"
+`;
+
+exports[`options > supports custom jsxAttributeName 2`] = `
+"/* babel-tailwind:/Users/aet/Documents/Git/babel-tailwind/src/.temp-options/index.css */
+.tw-gqn2k6 {
+ text-align: center;
+}
+"
+`;
diff --git a/src/__tests__/__snapshots__/spread.test.ts.snap b/src/__tests__/__snapshots__/spread.test.ts.snap
new file mode 100644
index 0000000..447fbf3
--- /dev/null
+++ b/src/__tests__/__snapshots__/spread.test.ts.snap
@@ -0,0 +1,51 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`spread > supports spread attribute in "css" attribute (2) 1`] = `
+"// src/.temp-spread/index.tsx
+import { cx as _cx } from "@emotion/css";
+import * as _tslib from "tslib";
+import { jsx } from "react/jsx-runtime";
+function Hello(props) {
+ props = {
+ ...props,
+ className: "text-center"
+ };
+ return /* @__PURE__ */ jsx("div", { ..._tslib.__rest(props, ["className"]), className: _cx("tw-gqn2k6", props.className), children: "Hello, world!" });
+}
+export {
+ Hello
+};
+"
+`;
+
+exports[`spread > supports spread attribute in "css" attribute (2) 2`] = `
+"/* babel-tailwind:/Users/aet/Documents/Git/babel-tailwind/src/.temp-spread/index.css */
+.tw-gqn2k6 {
+ text-align: center;
+}
+"
+`;
+
+exports[`spread > supports spread attribute in "css" attribute 1`] = `
+"// src/.temp-spread/index.tsx
+import { cx as _cx } from "@emotion/css";
+import { jsx } from "react/jsx-runtime";
+function Hello({
+ className: _className,
+ ...props
+}) {
+ return /* @__PURE__ */ jsx("div", { ...props, className: _cx("tw-gqn2k6", _className), children: "Hello, world!" });
+}
+export {
+ Hello
+};
+"
+`;
+
+exports[`spread > supports spread attribute in "css" attribute 2`] = `
+"/* babel-tailwind:/Users/aet/Documents/Git/babel-tailwind/src/.temp-spread/index.css */
+.tw-gqn2k6 {
+ text-align: center;
+}
+"
+`;
diff --git a/src/__tests__/__snapshots__/styleObject.test.ts.snap b/src/__tests__/__snapshots__/styleObject.test.ts.snap
new file mode 100644
index 0000000..8e08235
--- /dev/null
+++ b/src/__tests__/__snapshots__/styleObject.test.ts.snap
@@ -0,0 +1,56 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`babel-tailwind > supports .hover, .focus, .active, .group-hover, .group-focus, .group-active 1`] = `
+"// babel-tailwind:/Users/aet/Documents/Git/babel-tailwind/src/.temp-styleObject/index.tailwindStyle.js
+var tw_12x0ow0 = {
+ "&:focus": {
+ fontWeight: "700"
+ }
+};
+var tw_m4tg46 = {
+ "&:active": {
+ fontWeight: "300"
+ }
+};
+var tw_rqrjo3 = {
+ "&:hover:active": {
+ padding: "0.5rem"
+ }
+};
+var tw_1qtvvjy = {
+ "&:hover": {
+ fontWeight: "600"
+ }
+};
+
+// src/.temp-styleObject/index.tsx
+var style = [tw_1qtvvjy, tw_12x0ow0, tw_m4tg46, tw_rqrjo3];
+export {
+ style
+};
+"
+`;
+
+exports[`babel-tailwind > supports conversion into CSSProperties 1`] = `
+"// babel-tailwind:/Users/aet/Documents/Git/babel-tailwind/src/.temp-styleObject/index.tailwindStyle.js
+var tw_kt12th = {
+ padding: "0.5rem",
+ textAlign: "center",
+ "&:hover": {
+ fontWeight: "600"
+ },
+ "@media (min-width: 640px)": {
+ padding: "0.25rem"
+ }
+};
+
+// src/.temp-styleObject/index.tsx
+import { jsx } from "react/jsx-runtime";
+function Hello() {
+ return /* @__PURE__ */ jsx("div", { style: tw_kt12th, children: "Hello, world!" });
+}
+export {
+ Hello
+};
+"
+`;
diff --git a/src/__tests__/attr.test.ts b/src/__tests__/attr.test.ts
new file mode 100644
index 0000000..ed8e4cf
--- /dev/null
+++ b/src/__tests__/attr.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+import { getBuild } from "./utils";
+
+describe("attr", () => {
+ const compileESBuild = getBuild("attr");
+
+ it("supports grouped array css attribute", async () => {
+ const { files } = await compileESBuild({
+ clsx: "emotion",
+ expectFiles: 2,
+ javascript: /* tsx */ `
+ export function Hello() {
+ return (
+
+ Hello, world!
+
+ );
+ }
+ `,
+ });
+
+ expect(files.js.text).toMatchSnapshot();
+ expect(files.css.text).toMatchSnapshot();
+ });
+
+ it('supports conditional expression in "css" attribute', async () => {
+ const { files } = await compileESBuild({
+ clsx: "emotion",
+ expectFiles: 2,
+ javascript: /* tsx */ `
+ export function Hello({ isCenter }) {
+ return (
+
+ Hello, world!
+
+ );
+ }
+ `,
+ });
+
+ expect(files.js.text).toMatchSnapshot();
+ expect(files.css.text).toMatchSnapshot();
+ });
+});
diff --git a/src/__tests__/merge.test.ts b/src/__tests__/merge.test.ts
new file mode 100644
index 0000000..48bbb41
--- /dev/null
+++ b/src/__tests__/merge.test.ts
@@ -0,0 +1,50 @@
+/* eslint-disable unicorn/string-content */
+import { describe, expect, it } from "vitest";
+import { getClassName } from "../index";
+import { getBuild } from "./utils";
+
+describe("merges with existing className attribute", () => {
+ const compileESBuild = getBuild("merge");
+
+ it("string literal", async () => {
+ const { files } = await compileESBuild({
+ clsx: "emotion",
+ expectFiles: 2,
+ javascript: /* tsx */ `
+ export function Hello() {
+ return (
+
+ Hello, world!
+
+ );
+ }
+ `,
+ });
+
+ const clsName = getClassName("text-center");
+ expect(files.js.text).toContain(`className: "text-center ${clsName}"`);
+ expect(files.css.text).toMatch(`.${clsName} {\n text-align: center;\n}`);
+ });
+
+ it("existing function", async () => {
+ const { files } = await compileESBuild({
+ clsx: "emotion",
+ expectFiles: 2,
+ javascript: /* tsx */ `
+ export function Hello() {
+ return (
+ isEntering ? "enter" : "exit"} css="text-center">
+ Hello, world!
+
+ );
+ }
+ `,
+ });
+
+ const clsName = getClassName("text-center");
+ expect(files.js.text).toContain(
+ `className: ({\n isEntering\n }) => _cx(isEntering ? "enter" : "exit", "${clsName}")`
+ );
+ expect(files.css.text).toMatch(`.${clsName} {\n text-align: center;\n}`);
+ });
+});
diff --git a/src/__tests__/options.test.ts b/src/__tests__/options.test.ts
new file mode 100644
index 0000000..cbfef58
--- /dev/null
+++ b/src/__tests__/options.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+import { getBuild } from "./utils";
+
+describe("options", () => {
+ const compileESBuild = getBuild("options");
+ it("supports custom jsxAttributeName", async () => {
+ const { files } = await compileESBuild({
+ clsx: "emotion",
+ jsxAttributeName: "tw",
+ expectFiles: 2,
+ javascript: /* tsx */ `
+ export function Hello() {
+ return (
+
+ Hello, world!
+
+ );
+ }
+ `,
+ });
+
+ expect(files.js.text).toMatchSnapshot();
+ expect(files.css.text).toMatchSnapshot();
+ });
+
+ it("does not remove the attribute if `preserveAttribute` is true", async () => {
+ const { files } = await compileESBuild({
+ clsx: "emotion",
+ jsxAttributeAction: "preserve",
+ expectFiles: 2,
+ javascript: /* tsx */ `
+ export function Hello() {
+ return (
+
+ Hello, world!
+
+ );
+ }
+ `,
+ });
+
+ expect(files.js.text).toContain(`css: "text-center"`);
+ });
+});
diff --git a/src/__tests__/spread.test.ts b/src/__tests__/spread.test.ts
new file mode 100644
index 0000000..9fe10bf
--- /dev/null
+++ b/src/__tests__/spread.test.ts
@@ -0,0 +1,46 @@
+/* eslint-disable unicorn/string-content */
+import { describe, expect, it } from "vitest";
+import { getBuild } from "./utils";
+
+describe("spread", () => {
+ const compileESBuild = getBuild("spread");
+
+ it('supports spread attribute in "css" attribute', async () => {
+ const { files } = await compileESBuild({
+ clsx: "emotion",
+ expectFiles: 2,
+ javascript: /* tsx */ `
+ export function Hello(props) {
+ return (
+
+ Hello, world!
+
+ );
+ }
+ `,
+ });
+
+ expect(files.js.text).toMatchSnapshot();
+ expect(files.css.text).toMatchSnapshot();
+ });
+
+ it('supports spread attribute in "css" attribute (2)', async () => {
+ const { files } = await compileESBuild({
+ clsx: "emotion",
+ expectFiles: 2,
+ javascript: /* tsx */ `
+ export function Hello(props) {
+ props = { ...props, className: "text-center" };
+ return (
+
+ Hello, world!
+
+ );
+ }
+ `,
+ });
+
+ expect(files.js.text).toMatchSnapshot();
+ expect(files.css.text).toMatchSnapshot();
+ });
+});
diff --git a/src/__tests__/styleObject.test.ts b/src/__tests__/styleObject.test.ts
index 4452db6..45fe573 100644
--- a/src/__tests__/styleObject.test.ts
+++ b/src/__tests__/styleObject.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import { getBuild } from "./utils";
-import { getClassName } from "../index";
describe("babel-tailwind", () => {
const compileESBuild = getBuild("styleObject");
@@ -22,25 +21,7 @@ describe("babel-tailwind", () => {
`,
});
- const clsName = getClassName("p-2 text-center hover:font-semibold sm:p-1").replace(
- /^tw-/,
- "tw_"
- );
- expect(files.js.text).toContain(
- [
- `var ${clsName} = {`,
- ' padding: "0.5rem",',
- ' textAlign: "center",',
- ' "&:hover": {',
- ' fontWeight: "600"',
- " },",
- ' "@media (min-width: 640px)": {',
- ' padding: "0.25rem"',
- " }",
- "}",
- ].join("\n")
- );
- expect(files.js.text).toContain(`style: ${clsName}`);
+ expect(files.js.text).toMatchSnapshot();
});
it("supports .hover, .focus, .active, .group-hover, .group-focus, .group-active", async () => {
@@ -59,34 +40,6 @@ describe("babel-tailwind", () => {
`,
});
- const semibold = getClassName("hover:font-semibold").replace(/^tw-/, "tw_");
- const bold = getClassName("focus:font-bold").replace(/^tw-/, "tw_");
- const light = getClassName("active:font-light").replace(/^tw-/, "tw_");
- const p = getClassName("active:hover:p-2").replace(/^tw-/, "tw_");
-
- expect(files.js.text).toContain(
- [
- `var ${bold} = {`,
- ' "&:focus": {',
- ' fontWeight: "700"',
- " }",
- "};",
- `var ${light} = {`,
- ' "&:active": {',
- ' fontWeight: "300"',
- " }",
- "};",
- `var ${p} = {`,
- ' "&:hover:active": {',
- ' padding: "0.5rem"',
- " }",
- "};",
- `var ${semibold} = {`,
- ' "&:hover": {',
- ' fontWeight: "600"',
- " }",
- "};",
- ].join("\n")
- );
+ expect(files.js.text).toMatchSnapshot();
});
});
diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts
index 1760e8c..e58d98f 100644
--- a/src/__tests__/utils.ts
+++ b/src/__tests__/utils.ts
@@ -47,7 +47,7 @@ export function getBuild(name: string) {
const result = await esbuild.build({
bundle: true,
write: false,
- external: ["react/jsx-runtime", "@emotion/css", "clsx"],
+ external: ["react/jsx-runtime", "@emotion/css", "clsx", "tslib"],
outdir: "dist",
format: "esm",
entryPoints: [await write("index.tsx", dedent(javascript))],
diff --git a/src/babel-tailwind.ts b/src/babel-tailwind.ts
index 51057e0..af9f4fe 100644
--- a/src/babel-tailwind.ts
+++ b/src/babel-tailwind.ts
@@ -28,6 +28,7 @@ export function babelTailwind(
function getUtils(path: NodePath, state: b.PluginPass, t: BabelTypes) {
let cx: t.Identifier;
+ let tslibImport: t.Identifier;
let styleImport: t.Identifier;
const cssMap = new Map();
@@ -87,6 +88,19 @@ export function babelTailwind(
return t.cloneNode(cx);
},
+ getTSlibImport: () => {
+ if (tslibImport == null) {
+ tslibImport = path.scope.generateUidIdentifier("tslib");
+ path.node.body.unshift(
+ t.importDeclaration(
+ [t.importNamespaceSpecifier(tslibImport)],
+ t.stringLiteral("tslib")
+ )
+ );
+ }
+ return t.cloneNode(tslibImport);
+ },
+
finish(node: t.Program) {
const { filename } = state;
if (!cssMap.size && !jsMap.size) return;
@@ -279,12 +293,58 @@ export function babelTailwind(
}
}
} else {
- parent.attributes.push(
- t.jsxAttribute(
- t.jsxIdentifier("className"),
- t.jSXExpressionContainer(valuePathNode)
- )
- );
+ const wrap = (originalValue: b.types.Expression) =>
+ t.callExpression(_.getCx(), [valuePathNode, originalValue]);
+
+ const rest = parent.attributes.filter(attr => t.isJSXSpreadAttribute(attr));
+ let arg;
+ if (rest.length === 1 && (arg = rest[0].argument) && t.isIdentifier(arg)) {
+ // props from argument and not modified anywhere
+ const scope = path.scope.getBinding(arg.name);
+ let index: number;
+ if (
+ scope &&
+ !scope.constantViolations.length &&
+ t.isFunctionDeclaration(scope.path.parent) &&
+ (index = (scope.path.parent.params as t.Node[]).indexOf(scope.path.node)) !==
+ -1
+ ) {
+ const clsVar = path.scope.generateUidIdentifier("className");
+ scope.path.parent.params[index] = t.objectPattern([
+ t.objectProperty(t.identifier("className"), clsVar),
+ t.restElement(scope.path.node as t.Identifier),
+ ]);
+
+ parent.attributes.push(
+ t.jsxAttribute(
+ t.jsxIdentifier("className"),
+ t.jsxExpressionContainer(wrap(clsVar))
+ )
+ );
+ } else {
+ const tslibImport = _.getTSlibImport();
+ rest[0].argument = t.callExpression(
+ t.memberExpression(tslibImport, t.identifier("__rest")),
+ [arg, t.arrayExpression([t.stringLiteral("className")])]
+ );
+
+ parent.attributes.push(
+ t.jsxAttribute(
+ t.jsxIdentifier("className"),
+ t.jsxExpressionContainer(
+ wrap(t.memberExpression(arg, t.identifier("className")))
+ )
+ )
+ );
+ }
+ } else {
+ parent.attributes.push(
+ t.jsxAttribute(
+ t.jsxIdentifier("className"),
+ t.jsxExpressionContainer(valuePathNode)
+ )
+ );
+ }
}
if (jsxAttributeAction === "delete") {
diff --git a/src/index.test.ts b/src/index.test.ts
index cccf6e1..aab8494 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -1,4 +1,3 @@
-/* eslint-disable unicorn/string-content */
import { describe, expect, it } from "vitest";
import { getClassName } from "./index";
import { getBuild } from "./__tests__/utils";
@@ -25,137 +24,4 @@ describe("babel-tailwind", () => {
expect(files.js.text).toContain(`className: "${clsName}"`);
expect(files.css.text).toMatch(`.${clsName} {\n text-align: center;\n}`);
});
-
- it("does not remove the attribute if `preserveAttribute` is true", async () => {
- const { files } = await compileESBuild({
- clsx: "emotion",
- jsxAttributeAction: "preserve",
- expectFiles: 2,
- javascript: /* tsx */ `
- export function Hello() {
- return (
-
- Hello, world!
-
- );
- }
- `,
- });
-
- expect(files.js.text).toContain(`css: "text-center"`);
- });
-
- describe('merges with existing "className" attribute', () => {
- it("string literal", async () => {
- const { files } = await compileESBuild({
- clsx: "emotion",
- expectFiles: 2,
- javascript: /* tsx */ `
- export function Hello() {
- return (
-
- Hello, world!
-
- );
- }
- `,
- });
-
- const clsName = getClassName("text-center");
- expect(files.js.text).toContain(`className: "text-center ${clsName}"`);
- expect(files.css.text).toMatch(`.${clsName} {\n text-align: center;\n}`);
- });
-
- it("existing function", async () => {
- const { files } = await compileESBuild({
- clsx: "emotion",
- expectFiles: 2,
- javascript: /* tsx */ `
- export function Hello() {
- return (
- isEntering ? "enter" : "exit"} css="text-center">
- Hello, world!
-
- );
- }
- `,
- });
-
- const clsName = getClassName("text-center");
- expect(files.js.text).toContain(
- `className: ({\n isEntering\n }) => _cx(isEntering ? "enter" : "exit", "${clsName}")`
- );
- expect(files.css.text).toMatch(`.${clsName} {\n text-align: center;\n}`);
- });
- });
-
- it("supports custom jsxAttributeName", async () => {
- const { files } = await compileESBuild({
- clsx: "emotion",
- jsxAttributeName: "tw",
- expectFiles: 2,
- javascript: /* tsx */ `
- export function Hello() {
- return (
-
- Hello, world!
-
- );
- }
- `,
- });
-
- const clsName = getClassName("text-center");
- expect(files.js.text).toContain(`className: "${clsName}"`);
- expect(files.css.text).toMatch(`.${clsName} {\n text-align: center;\n}`);
- });
-
- it("supports grouped array css attribute", async () => {
- const { files } = await compileESBuild({
- clsx: "emotion",
- expectFiles: 2,
- javascript: /* tsx */ `
- export function Hello() {
- return (
-
- Hello, world!
-
- );
- }
- `,
- });
-
- const clsName = ["text-center", "hover:font-semibold"].map(getClassName);
- expect(files.js.text).toContain(`className: "${clsName.join(" ")}"`);
- expect(files.css.text).toMatch(
- [
- `.${clsName[0]} {`,
- " text-align: center;",
- "}",
- `.${clsName[1]}:hover {`,
- " font-weight: 600;",
- "}",
- ].join("\n")
- );
- });
-
- it('supports conditional expression in "css" attribute', async () => {
- const { files } = await compileESBuild({
- clsx: "emotion",
- expectFiles: 2,
- javascript: /* tsx */ `
- export function Hello({ isCenter }) {
- return (
-
- Hello, world!
-
- );
- }
- `,
- });
-
- const clsName = getClassName("text-center");
- expect(files.js.text).toContain(`className: isCenter ? "${clsName}" : void 0`);
- expect(files.css.text).toMatch(`.${clsName} {\n text-align: center;\n}`);
- });
});