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}`); - }); });