diff --git a/.vscode/settings.json b/.vscode/settings.json index e1f6772..ad92582 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,3 @@ { - "editor.formatOnSave": true, - "eslint.runtime": "node" -} \ No newline at end of file + "editor.formatOnSave": true +} diff --git a/package.json b/package.json index 29c6b81..d317606 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aet/tailwind", - "version": "0.0.1-beta.20", + "version": "0.0.1-beta.21", "main": "dist/index.js", "license": "MIT", "scripts": { @@ -32,7 +32,7 @@ "prettier": "^3.3.2", "tailwindcss": "^3.4.4", "tsup": "^8.1.0", - "typescript": "^5.5.2", + "typescript": "^5.5.3", "vite": "^5.3.2", "vitest": "^1.6.0" }, @@ -43,9 +43,10 @@ "@babel/core": "^7.24.7", "@emotion/hash": "^0.9.1", "lodash": "^4.17.21", - "postcss": "^8.4.38", + "postcss": "^8.4.39", + "postcss-selector-parser": "^6.1.0", "tiny-invariant": "^1.3.3", - "type-fest": "^4.20.1" + "type-fest": "^4.21.0" }, "prettier": { "arrowParens": "avoid", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d51c51..4b8f366 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,18 +18,18 @@ importers: specifier: ^4.17.21 version: 4.17.21 postcss: - specifier: ^8.4.38 - version: 8.4.38 + specifier: ^8.4.39 + version: 8.4.39 tiny-invariant: specifier: ^1.3.3 version: 1.3.3 type-fest: - specifier: ^4.20.1 - version: 4.20.1 + specifier: ^4.21.0 + version: 4.21.0 devDependencies: '@aet/eslint-rules': specifier: ^0.0.34 - version: 0.0.34(eslint@8.57.0)(typescript@5.5.2) + version: 0.0.34(eslint@8.57.0)(typescript@5.5.3) '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 @@ -77,10 +77,13 @@ importers: version: 8.57.0 postcss-nested: specifier: ^6.0.1 - version: 6.0.1(postcss@8.4.38) + version: 6.0.1(postcss@8.4.39) postcss-safe-parser: specifier: ^7.0.0 - version: 7.0.0(postcss@8.4.38) + version: 7.0.0(postcss@8.4.39) + postcss-selector-parser: + specifier: ^6.1.0 + version: 6.1.0 prettier: specifier: ^3.3.2 version: 3.3.2 @@ -89,10 +92,10 @@ importers: version: 3.4.4 tsup: specifier: ^8.1.0 - version: 8.1.0(postcss@8.4.38)(typescript@5.5.2) + version: 8.1.0(postcss@8.4.39)(typescript@5.5.3) typescript: - specifier: ^5.5.2 - version: 5.5.2 + specifier: ^5.5.3 + version: 5.5.3 vite: specifier: ^5.3.2 version: 5.3.2(@types/node@20.14.9) @@ -1630,6 +1633,9 @@ packages: picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + picocolors@1.0.1: + resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -1685,15 +1691,15 @@ packages: peerDependencies: postcss: ^8.4.31 - postcss-selector-parser@6.0.16: - resolution: {integrity: sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==} + postcss-selector-parser@6.1.0: + resolution: {integrity: sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==} engines: {node: '>=4'} postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.4.38: - resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} + postcss@8.4.39: + resolution: {integrity: sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -2011,12 +2017,12 @@ packages: resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} engines: {node: '>=8'} - type-fest@4.20.1: - resolution: {integrity: sha512-R6wDsVsoS9xYOpy8vgeBlqpdOyzJ12HNfQhC/aAKWM3YoCV9TtunJzh/QpkMgeDhkoynDcw5f1y+qF9yc/HHyg==} + type-fest@4.21.0: + resolution: {integrity: sha512-ADn2w7hVPcK6w1I0uWnM//y1rLXZhzB9mr0a3OirzclKF1Wp6VzevUmzz/NRAWunOT6E8HrnpGY7xOfc6K57fA==} engines: {node: '>=16'} - typescript@5.5.2: - resolution: {integrity: sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==} + typescript@5.5.3: + resolution: {integrity: sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==} engines: {node: '>=14.17'} hasBin: true @@ -2160,15 +2166,15 @@ snapshots: '@aet/eslint-define-config@0.1.0-beta.1': {} - '@aet/eslint-rules@0.0.34(eslint@8.57.0)(typescript@5.5.2)': + '@aet/eslint-rules@0.0.34(eslint@8.57.0)(typescript@5.5.3)': dependencies: '@aet/eslint-define-config': 0.1.0-beta.1 '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) '@types/eslint': 8.56.10 - '@typescript-eslint/eslint-plugin': 7.14.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.5.2))(eslint@8.57.0)(typescript@5.5.2) - '@typescript-eslint/parser': 7.14.1(eslint@8.57.0)(typescript@5.5.2) - '@typescript-eslint/type-utils': 7.14.1(eslint@8.57.0)(typescript@5.5.2) - '@typescript-eslint/utils': 7.14.1(eslint@8.57.0)(typescript@5.5.2) + '@typescript-eslint/eslint-plugin': 7.14.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3) + '@typescript-eslint/parser': 7.14.1(eslint@8.57.0)(typescript@5.5.3) + '@typescript-eslint/type-utils': 7.14.1(eslint@8.57.0)(typescript@5.5.3) + '@typescript-eslint/utils': 7.14.1(eslint@8.57.0)(typescript@5.5.3) aria-query: 5.3.0 axe-core: 4.9.1 axobject-query: 4.0.0 @@ -2180,9 +2186,9 @@ snapshots: eslint: 8.57.0 eslint-config-prettier: 9.1.0(eslint@8.57.0) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.5.2))(eslint-import-resolver-node@0.3.9)(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint@8.57.0) eslint-plugin-es-x: 7.7.0(eslint@8.57.0) - eslint-plugin-import-x: 0.5.2(eslint@8.57.0)(typescript@5.5.2) + eslint-plugin-import-x: 0.5.2(eslint@8.57.0)(typescript@5.5.3) eslint-plugin-jsdoc: 48.5.0(eslint@8.57.0) eslint-plugin-unicorn: 54.0.0(eslint@8.57.0) esprima: 4.0.1 @@ -2199,7 +2205,7 @@ snapshots: resolve: 2.0.0-next.5 semver: 7.6.2 tsconfig-paths: 4.2.0 - typescript: 5.5.2 + typescript: 5.5.3 transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -2636,7 +2642,7 @@ snapshots: '@types/postcss-safe-parser@5.0.4': dependencies: - postcss: 8.4.38 + postcss: 8.4.39 '@types/prop-types@15.7.12': {} @@ -2649,34 +2655,34 @@ snapshots: dependencies: '@types/node': 20.14.9 - '@typescript-eslint/eslint-plugin@7.14.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.5.2))(eslint@8.57.0)(typescript@5.5.2)': + '@typescript-eslint/eslint-plugin@7.14.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.5.3))(eslint@8.57.0)(typescript@5.5.3)': dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 7.14.1(eslint@8.57.0)(typescript@5.5.2) + '@typescript-eslint/parser': 7.14.1(eslint@8.57.0)(typescript@5.5.3) '@typescript-eslint/scope-manager': 7.14.1 - '@typescript-eslint/type-utils': 7.14.1(eslint@8.57.0)(typescript@5.5.2) - '@typescript-eslint/utils': 7.14.1(eslint@8.57.0)(typescript@5.5.2) + '@typescript-eslint/type-utils': 7.14.1(eslint@8.57.0)(typescript@5.5.3) + '@typescript-eslint/utils': 7.14.1(eslint@8.57.0)(typescript@5.5.3) '@typescript-eslint/visitor-keys': 7.14.1 eslint: 8.57.0 graphemer: 1.4.0 ignore: 5.3.1 natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.5.2) + ts-api-utils: 1.3.0(typescript@5.5.3) optionalDependencies: - typescript: 5.5.2 + typescript: 5.5.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.5.2)': + '@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.5.3)': dependencies: '@typescript-eslint/scope-manager': 7.14.1 '@typescript-eslint/types': 7.14.1 - '@typescript-eslint/typescript-estree': 7.14.1(typescript@5.5.2) + '@typescript-eslint/typescript-estree': 7.14.1(typescript@5.5.3) '@typescript-eslint/visitor-keys': 7.14.1 debug: 4.3.5 eslint: 8.57.0 optionalDependencies: - typescript: 5.5.2 + typescript: 5.5.3 transitivePeerDependencies: - supports-color @@ -2685,21 +2691,21 @@ snapshots: '@typescript-eslint/types': 7.14.1 '@typescript-eslint/visitor-keys': 7.14.1 - '@typescript-eslint/type-utils@7.14.1(eslint@8.57.0)(typescript@5.5.2)': + '@typescript-eslint/type-utils@7.14.1(eslint@8.57.0)(typescript@5.5.3)': dependencies: - '@typescript-eslint/typescript-estree': 7.14.1(typescript@5.5.2) - '@typescript-eslint/utils': 7.14.1(eslint@8.57.0)(typescript@5.5.2) + '@typescript-eslint/typescript-estree': 7.14.1(typescript@5.5.3) + '@typescript-eslint/utils': 7.14.1(eslint@8.57.0)(typescript@5.5.3) debug: 4.3.5 eslint: 8.57.0 - ts-api-utils: 1.3.0(typescript@5.5.2) + ts-api-utils: 1.3.0(typescript@5.5.3) optionalDependencies: - typescript: 5.5.2 + typescript: 5.5.3 transitivePeerDependencies: - supports-color '@typescript-eslint/types@7.14.1': {} - '@typescript-eslint/typescript-estree@7.14.1(typescript@5.5.2)': + '@typescript-eslint/typescript-estree@7.14.1(typescript@5.5.3)': dependencies: '@typescript-eslint/types': 7.14.1 '@typescript-eslint/visitor-keys': 7.14.1 @@ -2708,18 +2714,18 @@ snapshots: is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.6.2 - ts-api-utils: 1.3.0(typescript@5.5.2) + ts-api-utils: 1.3.0(typescript@5.5.3) optionalDependencies: - typescript: 5.5.2 + typescript: 5.5.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@7.14.1(eslint@8.57.0)(typescript@5.5.2)': + '@typescript-eslint/utils@7.14.1(eslint@8.57.0)(typescript@5.5.3)': dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) '@typescript-eslint/scope-manager': 7.14.1 '@typescript-eslint/types': 7.14.1 - '@typescript-eslint/typescript-estree': 7.14.1(typescript@5.5.2) + '@typescript-eslint/typescript-estree': 7.14.1(typescript@5.5.3) eslint: 8.57.0 transitivePeerDependencies: - supports-color @@ -3084,11 +3090,11 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.5.2))(eslint-import-resolver-node@0.3.9)(eslint@8.57.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.5.3))(eslint-import-resolver-node@0.3.9)(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 7.14.1(eslint@8.57.0)(typescript@5.5.2) + '@typescript-eslint/parser': 7.14.1(eslint@8.57.0)(typescript@5.5.3) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: @@ -3101,9 +3107,9 @@ snapshots: eslint: 8.57.0 eslint-compat-utils: 0.5.1(eslint@8.57.0) - eslint-plugin-import-x@0.5.2(eslint@8.57.0)(typescript@5.5.2): + eslint-plugin-import-x@0.5.2(eslint@8.57.0)(typescript@5.5.3): dependencies: - '@typescript-eslint/utils': 7.14.1(eslint@8.57.0)(typescript@5.5.2) + '@typescript-eslint/utils': 7.14.1(eslint@8.57.0)(typescript@5.5.3) debug: 4.3.5 doctrine: 3.0.0 eslint: 8.57.0 @@ -3704,6 +3710,8 @@ snapshots: picocolors@1.0.0: {} + picocolors@1.0.1: {} + picomatch@2.3.1: {} pify@2.3.0: {} @@ -3718,45 +3726,45 @@ snapshots: pluralize@8.0.0: {} - postcss-import@15.1.0(postcss@8.4.38): + postcss-import@15.1.0(postcss@8.4.39): dependencies: - postcss: 8.4.38 + postcss: 8.4.39 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.8 - postcss-js@4.0.1(postcss@8.4.38): + postcss-js@4.0.1(postcss@8.4.39): dependencies: camelcase-css: 2.0.1 - postcss: 8.4.38 + postcss: 8.4.39 - postcss-load-config@4.0.2(postcss@8.4.38): + postcss-load-config@4.0.2(postcss@8.4.39): dependencies: lilconfig: 3.1.1 yaml: 2.4.1 optionalDependencies: - postcss: 8.4.38 + postcss: 8.4.39 - postcss-nested@6.0.1(postcss@8.4.38): + postcss-nested@6.0.1(postcss@8.4.39): dependencies: - postcss: 8.4.38 - postcss-selector-parser: 6.0.16 + postcss: 8.4.39 + postcss-selector-parser: 6.1.0 - postcss-safe-parser@7.0.0(postcss@8.4.38): + postcss-safe-parser@7.0.0(postcss@8.4.39): dependencies: - postcss: 8.4.38 + postcss: 8.4.39 - postcss-selector-parser@6.0.16: + postcss-selector-parser@6.1.0: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 postcss-value-parser@4.2.0: {} - postcss@8.4.38: + postcss@8.4.39: dependencies: nanoid: 3.3.7 - picocolors: 1.0.0 + picocolors: 1.0.1 source-map-js: 1.2.0 prelude-ls@1.2.1: {} @@ -3981,12 +3989,12 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.0.0 - postcss: 8.4.38 - postcss-import: 15.1.0(postcss@8.4.38) - postcss-js: 4.0.1(postcss@8.4.38) - postcss-load-config: 4.0.2(postcss@8.4.38) - postcss-nested: 6.0.1(postcss@8.4.38) - postcss-selector-parser: 6.0.16 + postcss: 8.4.39 + postcss-import: 15.1.0(postcss@8.4.39) + postcss-js: 4.0.1(postcss@8.4.39) + postcss-load-config: 4.0.2(postcss@8.4.39) + postcss-nested: 6.0.1(postcss@8.4.39) + postcss-selector-parser: 6.1.0 resolve: 1.22.8 sucrase: 3.35.0 transitivePeerDependencies: @@ -4024,9 +4032,9 @@ snapshots: tree-kill@1.2.2: {} - ts-api-utils@1.3.0(typescript@5.5.2): + ts-api-utils@1.3.0(typescript@5.5.3): dependencies: - typescript: 5.5.2 + typescript: 5.5.3 ts-interface-checker@0.1.13: {} @@ -4038,7 +4046,7 @@ snapshots: tslib@2.6.3: {} - tsup@8.1.0(postcss@8.4.38)(typescript@5.5.2): + tsup@8.1.0(postcss@8.4.39)(typescript@5.5.3): dependencies: bundle-require: 4.0.2(esbuild@0.21.5) cac: 6.7.14 @@ -4048,15 +4056,15 @@ snapshots: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss-load-config: 4.0.2(postcss@8.4.38) + postcss-load-config: 4.0.2(postcss@8.4.39) resolve-from: 5.0.0 rollup: 4.13.2 source-map: 0.8.0-beta.0 sucrase: 3.35.0 tree-kill: 1.2.2 optionalDependencies: - postcss: 8.4.38 - typescript: 5.5.2 + postcss: 8.4.39 + typescript: 5.5.3 transitivePeerDependencies: - supports-color - ts-node @@ -4073,9 +4081,9 @@ snapshots: type-fest@0.8.1: {} - type-fest@4.20.1: {} + type-fest@4.21.0: {} - typescript@5.5.2: {} + typescript@5.5.3: {} ufo@1.5.3: {} @@ -4118,7 +4126,7 @@ snapshots: vite@5.3.2(@types/node@20.14.9): dependencies: esbuild: 0.21.5 - postcss: 8.4.38 + postcss: 8.4.39 rollup: 4.13.2 optionalDependencies: '@types/node': 20.14.9 diff --git a/scripts/index.ts b/scripts/index.ts index baf1160..ea768fb 100755 --- a/scripts/index.ts +++ b/scripts/index.ts @@ -28,6 +28,7 @@ await Promise.all([ build({ ...tsupConfig, entry: ["src/index.ts"], + external: ["postcss-selector-parser", "postcss"], }), Bun.write( "dist/package.json", diff --git a/src/__tests__/base.test.ts b/src/__tests__/base.test.ts new file mode 100644 index 0000000..b4f5d1d --- /dev/null +++ b/src/__tests__/base.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { createPostCSS } from "../index"; +import { getBuild, minCSS, name } from "./utils"; + +describe("babel-tailwind", () => { + const compileESBuild = getBuild("base"); + + it("supports importing tailwind/base", async () => { + const postcss = createPostCSS({ + tailwindConfig: {}, + postCSSPlugins: [], + }); + const base = await postcss("@tailwind base;"); + const { files } = await compileESBuild({ + clsx: "emotion", + expectFiles: 2, + javascript: /* tsx */ ` + import "${name}/base"; + `, + }); + + expect(files.js.text).toBe(""); + expect(minCSS(files.css.text)).toContain(minCSS(base)); + }); +}); diff --git a/src/__tests__/error.test.ts b/src/__tests__/error.test.ts new file mode 100644 index 0000000..30786a7 --- /dev/null +++ b/src/__tests__/error.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { getBuild } from "./utils"; + +describe("babel-tailwind", () => { + const compileESBuild = getBuild("error"); + it("reports errors with correct position", async () => { + try { + await compileESBuild({ + clsx: "emotion", + jsxAttributeAction: "preserve", + esbuild: { + logLevel: "silent", + }, + javascript: /* tsx */ ` + export function Hello() { + return ( +
+ Hello, world! +
+ ); + } + `, + }); + throw new Error("Expected an error"); + } catch (e) { + expect(e.errors).toHaveLength(1); + const [error] = e.errors; + expect(error.location).toMatchObject({ + column: 14, + length: 12, + line: 3, + lineText: '
', + }); + } + }); +}); diff --git a/src/__tests__/styleObject.test.ts b/src/__tests__/styleObject.test.ts new file mode 100644 index 0000000..33671e6 --- /dev/null +++ b/src/__tests__/styleObject.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { getBuild } from "./utils"; +import { getClassName } from "../index"; + +describe.only("babel-tailwind", () => { + const compileESBuild = getBuild("styleObject"); + + it("supports conversion into CSSProperties", async () => { + const { files } = await compileESBuild({ + clsx: "emotion", + expectFiles: 1, + javascript: ` + export function Hello() { + return ( +
+ Hello, world! +
+ ); + } + `, + }); + + const clsName = getClassName("p-2 text-center").replace(/^tw-/, "tw_"); + expect(files.js.text).toContain( + `var ${clsName} = {\n "padding": "0.5rem",\n "textAlign": "center"\n}` + ); + expect(files.js.text).toContain(`style: ${clsName}`); + }); +}); diff --git a/src/__tests__/tw.test.ts b/src/__tests__/tw.test.ts new file mode 100644 index 0000000..5a1c705 --- /dev/null +++ b/src/__tests__/tw.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { getClassName } from "../index"; +import { getBuild } from "./utils"; + +describe("babel-tailwind", () => { + const compileESBuild = getBuild("tw"); + it("supports grouped tw", async () => { + const { files } = await compileESBuild({ + clsx: "emotion", + expectFiles: 2, + javascript: ` + export default tw("text-sm", \`flex\`, { + "group-hover": "text-center", + "[&>div]": \`font-semibold\`, + data: { + "name='hello'": "text-right", + nested: { + true: "border", + } + }, + }) + `, + }); + + const clsName = getClassName( + "text-sm flex group-hover:text-center [&>div]:font-semibold data-[name='hello']:text-right data-[nested=true]:border" + ); + expect(files.js.text).toContain(`= "${clsName}"`); + expect(files.css.text).toMatch( + [ + `.${clsName} {`, + " display: flex;", + " font-size: 0.875rem;", + " line-height: 1.25rem;", + "}", + `.group:hover .${clsName} {`, + " text-align: center;", + "}", + `.${clsName}[data-nested=true] {`, + " border-width: 1px;", + "}", + `.${clsName}[data-name=hello] {`, + " text-align: right;", + "}", + `.${clsName} > div {`, + " font-weight: 600;", + "}", + ].join("\n") + ); + }); +}); diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts new file mode 100644 index 0000000..3e376d0 --- /dev/null +++ b/src/__tests__/utils.ts @@ -0,0 +1,75 @@ +import { promises as fs } from "node:fs"; +import { resolve } from "node:path"; +import { afterEach, beforeEach, expect } from "vitest"; +import * as esbuild from "esbuild"; +import dedent from "dedent"; +import { type TailwindPluginOptions, babelPlugin, getTailwindPlugins } from "../index"; + +export { name } from "../../package.json" with { type: "json" }; + +export const minCSS = (text: string) => + esbuild.transformSync(text, { minify: true, loader: "css" }).code; + +const findByExt = (outputFiles: esbuild.OutputFile[], ext: string) => + outputFiles.find(file => file.path.endsWith(ext))!; + +export function getBuild(name: string) { + const folder = resolve(import.meta.dirname, "..", ".temp-" + name); + + async function write(path: string, content: string) { + const resolved = resolve(folder, path); + await fs.writeFile(resolved, content); + return resolved; + } + + beforeEach(async () => { + await fs.mkdir(folder, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(folder, { recursive: true, force: true }); + }); + + return async function compileESBuild({ + javascript, + esbuild: esbuildOptions, + expectFiles, + ...options + }: Omit & { + esbuild?: esbuild.BuildOptions; + javascript: string; + expectFiles?: number; + }) { + const tailwind = getTailwindPlugins({ + tailwindConfig: {}, + macroFunction: "tw", + macroStyleFunction: "tws", + ...options, + }); + const result = await esbuild.build({ + bundle: true, + write: false, + external: ["react/jsx-runtime", "@emotion/css", "clsx"], + outdir: "dist", + format: "esm", + entryPoints: [await write("index.tsx", dedent(javascript))], + plugins: [babelPlugin({ plugins: [tailwind.babel()] }), tailwind.esbuild()], + ...esbuildOptions, + }); + + const { errors, warnings, outputFiles } = result; + expect(errors).toHaveLength(0); + expect(warnings).toHaveLength(0); + + if (expectFiles != null) { + expect(outputFiles).toHaveLength(expectFiles); + } + + return { + outputFiles: outputFiles!, + files: new Proxy({} as Record, { + get: (_, ext: string) => findByExt(outputFiles!, ext), + }), + }; + }; +} diff --git a/src/babel-tailwind.ts b/src/babel-tailwind.ts index e78250a..24ff431 100644 --- a/src/babel-tailwind.ts +++ b/src/babel-tailwind.ts @@ -7,46 +7,8 @@ import { type NodePath, type types as t } from "@babel/core"; import type { SourceLocation, StyleMapEntry } from "./shared"; import { type ResolveTailwindOptions, getClassName } from "./index"; -const definePlugin = - (fn: (runtime: typeof babel) => babel.Visitor) => - (runtime: typeof babel) => { - const plugin: babel.PluginObj = { - visitor: fn(runtime), - }; - return plugin as babel.PluginObj; - }; - -const extractJSXContainer = (attr: NonNullable): t.Expression => - attr.type === "JSXExpressionContainer" ? (attr.expression as t.Expression) : attr; - -function matchPath( - nodePath: NodePath, - fns: (dig: (nodePath: NodePath) => void) => babel.Visitor -) { - if (!nodePath.node) return; - const fn = fns(path => matchPath(path, fns))[nodePath.node.type] as any; - fn?.(nodePath); -} - -interface BabelPluginState { - getCx: () => t.Identifier; - sliceText: (node: t.Node) => SourceLocation; - recordIfAbsent: (node: StyleMapEntry) => void; - tailwindMap: Map; -} - export type ClassNameCollector = (path: string, entries: StyleMapEntry[]) => void; - -const trim = (value: string) => value.replace(/\s+/g, " ").trim(); -const trimPrefix = (cls: string, prefix: string) => - trim(cls) - .split(" ") - .map(value => prefix + value); - -const flatMapEntries = ( - map: Record, - fn: (value: V, key: K) => R[] -): R[] => Object.entries(map).flatMap(([key, value]) => fn(value as V, key as K)); +type Type = "css" | "js"; export function babelTailwind( { @@ -54,168 +16,172 @@ export function babelTailwind( clsx, getClassName: getClass = getClassName, macroFunction, + macroStyleFunction, jsxAttributeAction = "delete", jsxAttributeName = "css", vite, }: ResolveTailwindOptions, onCollect: ClassNameCollector | undefined ) { - function getClsxImport(t: typeof babel.types, cx: t.Identifier) { - switch (clsx) { - case "emotion": - return t.importDeclaration( - [t.importSpecifier(cx, t.identifier("cx"))], - t.stringLiteral("@emotion/css") - ); - case "clsx": - return t.importDeclaration( - [t.importDefaultSpecifier(cx)], - t.stringLiteral("clsx") - ); - case "classnames": - return t.importDeclaration( - [t.importDefaultSpecifier(cx)], - t.stringLiteral("classnames") - ); - default: - throw new Error("Unknown clsx library"); + type BabelPluginState = ReturnType; + + function getState( + path: NodePath, + state: babel.PluginPass, + t: typeof babel.types + ) { + let cx: t.Identifier; + let styleImport: t.Identifier; + + const cssMap = new Map(); + const jsMap = new Map(); + + function getStyleImport() { + styleImport ??= path.scope.generateUidIdentifier("styles"); + return t.cloneNode(styleImport); } - } - function evaluateArgs(paths: NodePath[]) { - return paths - .flatMap(path => { - const { confident, value } = path.evaluate(); - invariant(confident, `${macroFunction} argument cannot be statically evaluated`); + return { + getClass(type: Type, value: string) { + return type === "css" ? getClass(value) : "tw_" + hash(value); + }, - if (typeof value === "string") { - return trim(value); + sliceText: (node: t.Node): SourceLocation => ({ + filename: state.filename!, + start: node.loc!.start, + end: node.loc!.end, + text: state.file.code + .split("\n") + .slice(node.loc!.start.line - 1, node.loc!.end.line) + .join("\n"), + }), + + recordIfAbsent(type: Type, entry: StyleMapEntry) { + const map = type === "css" ? cssMap : jsMap; + if (!map.has(entry.key)) { + map.set(entry.key, entry); + } + }, + + replaceWithImport(type: Type, path: NodePath, className: string) { + if (type === "css") { + path.replaceWith(t.stringLiteral(className)); + } else { + const styleImportId = getStyleImport(); + path.replaceWith( + t.memberExpression(styleImportId, t.stringLiteral(className), true) + ); + } + }, + + getCx: () => { + if (cx == null) { + cx = path.scope.generateUidIdentifier("cx"); + path.node.body.unshift(getClsxImport(t, cx, clsx)); + } + return t.cloneNode(cx); + }, + + finish(node: t.Program) { + const { filename } = state; + if (!cssMap.size && !jsMap.size) return; + invariant(filename, "babel: missing state.filename"); + + if (cssMap.size) { + const cssName = basename(filename, extname(filename)) + ".css"; + const path = join(dirname(filename), cssName); + const value = Array.from(cssMap.values()); + const importee = `tailwind:./${cssName}` + getSuffix(vite, value); + + node.body.unshift(t.importDeclaration([], t.stringLiteral(importee))); + + styleMap.set(path, value); + onCollect?.(path, value); } - if (isPlainObject(value)) { - return flatMapEntries(value, (classes, modifier) => { - if (modifier === "data" && isPlainObject(classes)) { - return flatMapEntries( - classes as Record, - (cls, key) => - typeof cls === "string" - ? trimPrefix(cls, `${modifier}-[${key}]:`) - : flatMapEntries(cls as Record, (cls, attrValue) => - trimPrefix(cls, `${modifier}-[${key}=${attrValue}]:`) - ) - ); - } + if (jsMap.size) { + const jsName = basename(filename, extname(filename)) + ".tailwindStyle.js"; + const path = join(dirname(filename), jsName); + const value = Array.from(jsMap.values()); + const importee = `tailwind:./${jsName}` + getSuffix(vite, value); - invariant( - typeof classes === "string", - `Value for "${modifier}" should be a string` - ); - return trimPrefix(classes, modifier + ":"); - }); + node.body.unshift( + t.importDeclaration( + [t.importNamespaceSpecifier(getStyleImport())], + t.stringLiteral(importee) + ) + ); + styleMap.set(path, value); + onCollect?.(path, value); } - - throw new Error(`${macroFunction} argument has an invalid type`); - }) - .join(" "); + }, + }; } + const getType = (fnName: string): Type | undefined => + fnName === macroFunction ? "css" : fnName === macroStyleFunction ? "js" : undefined; + return definePlugin(({ types: t }) => ({ Program: { enter(path, state) { - let cx: t.Identifier; - const map = (state.tailwindMap = new Map()); - state.sliceText = node => ({ - filename: state.filename!, - start: node.loc!.start, - end: node.loc!.end, - text: state.file.code - .split("\n") - .slice(node.loc!.start.line - 1, node.loc!.end.line) - .join("\n"), - }); - state.recordIfAbsent = entry => { - if (!map.has(entry.key)) { - map.set(entry.key, entry); - } - }; - state.getCx = () => { - if (cx == null) { - cx = path.scope.generateUidIdentifier("cx"); - path.node.body.unshift(getClsxImport(t, cx)); - } - return t.cloneNode(cx); - }; + Object.assign(state, getState(path, state, t)); }, - - exit({ node }, { filename, tailwindMap }) { - if (!tailwindMap.size) return; - invariant(filename, "babel: missing state.filename"); - - const cssName = basename(filename, extname(filename)) + ".css"; - - const path = join(dirname(filename), cssName); - const value = Array.from(tailwindMap.values()); - - let importee = `tailwind:./${cssName}`; - if (vite) { - const cacheKey = hash(value.map(x => x.className).join(",")); - importee += `?${cacheKey}`; - } - - node.body.unshift(t.importDeclaration([], t.stringLiteral(importee))); - - styleMap.set(path, value); - onCollect?.(path, value); + exit({ node }, _) { + _.finish(node); }, }, - TaggedTemplateExpression(path, { sliceText, recordIfAbsent }) { - if (macroFunction == null) return; + TaggedTemplateExpression(path, _) { + if (macroFunction == null && macroStyleFunction == null) return; const { node } = path; const { tag, quasi: { quasis, expressions }, } = node; - if (!t.isIdentifier(tag, { name: macroFunction })) return; + if (!t.isIdentifier(tag)) return; invariant( !expressions.length, `${macroFunction}\`\` should not contain expressions` ); + const type = getType(tag.name); + if (!type) return; + const value = quasis[0].value.cooked; if (value) { const trimmed = trim(value); - const className = getClass(trimmed); - recordIfAbsent({ + const className = _.getClass(type, trimmed); + _.recordIfAbsent(type, { key: className, className: trimmed, - location: sliceText(node), + location: _.sliceText(node), }); - path.replaceWith(t.stringLiteral(className)); + _.replaceWithImport(type, path, className); } }, - CallExpression(path, { sliceText, recordIfAbsent }) { + CallExpression(path, _) { if (macroFunction == null) return; - const { node } = path; - const { callee } = node; - if (!t.isIdentifier(callee, { name: macroFunction })) return; + if (!t.isIdentifier(callee)) return; - const trimmed = evaluateArgs(path.get("arguments")); + const type = getType(callee.name); + if (!type) return; + + const trimmed = path.get("arguments").flatMap(evaluateArgs).join(" "); const className = getClass(trimmed); - recordIfAbsent({ + _.recordIfAbsent(type, { key: className, className: trimmed, - location: sliceText(node), + location: _.sliceText(node), }); - path.replaceWith(t.stringLiteral(className)); + _.replaceWithImport(type, path, className); }, - JSXAttribute(path, { sliceText, recordIfAbsent, getCx }) { + JSXAttribute(path, _) { const { name } = path.node; if (name.name !== jsxAttributeName) return; @@ -238,10 +204,10 @@ export function babelTailwind( const trimmed = trim(value); if (trimmed) { const className = getClass(trimmed); - recordIfAbsent({ + _.recordIfAbsent("css", { key: className, className: trimmed, - location: sliceText(node), + location: _.sliceText(node), }); path.replaceWith(t.stringLiteral(className)); } @@ -252,12 +218,12 @@ export function babelTailwind( } }, ObjectExpression(path) { - const trimmed = evaluateArgs([path]); + const trimmed = evaluateArgs(path).join(" "); const className = getClass(trimmed); - recordIfAbsent({ + _.recordIfAbsent("css", { key: className, className: trimmed, - location: sliceText(path.node), + location: _.sliceText(path.node), }); path.replaceWith(t.stringLiteral(className)); }, @@ -292,7 +258,7 @@ export function babelTailwind( if (classNameAttribute) { const attrValue = classNameAttribute.value!; const wrap = (originalValue: babel.types.Expression) => - t.callExpression(getCx(), [originalValue, valuePathNode]); + t.callExpression(_.getCx(), [originalValue, valuePathNode]); // If both are string literals, we can merge them directly here if (t.isStringLiteral(attrValue) && t.isStringLiteral(valuePathNode)) { @@ -313,7 +279,7 @@ export function babelTailwind( parent.attributes.push( t.jsxAttribute( t.jsxIdentifier("className"), - t.jSXExpressionContainer(valuePathNode!) + t.jSXExpressionContainer(valuePathNode) ) ); } @@ -329,3 +295,92 @@ export function babelTailwind( }, })); } + +function getClsxImport(t: typeof babel.types, cx: t.Identifier, clsx: string) { + switch (clsx) { + case "emotion": + return t.importDeclaration( + [t.importSpecifier(cx, t.identifier("cx"))], + t.stringLiteral("@emotion/css") + ); + case "clsx": + return t.importDeclaration([t.importDefaultSpecifier(cx)], t.stringLiteral("clsx")); + case "classnames": + return t.importDeclaration( + [t.importDefaultSpecifier(cx)], + t.stringLiteral("classnames") + ); + default: + throw new Error("Unknown clsx library"); + } +} + +function evaluateArgs(path: NodePath) { + const { confident, value } = path.evaluate(); + invariant(confident, "Argument cannot be statically evaluated"); + + if (typeof value === "string") { + return [trim(value)]; + } + + if (isPlainObject(value)) { + return flatMapEntries(value, (classes, modifier) => { + if (modifier === "data" && isPlainObject(classes)) { + return flatMapEntries(classes as Record, (cls, key) => + typeof cls === "string" + ? trimPrefix(cls, `${modifier}-[${key}]:`) + : flatMapEntries(cls as Record, (cls, attrValue) => + trimPrefix(cls, `${modifier}-[${key}=${attrValue}]:`) + ) + ); + } + + invariant( + typeof classes === "string", + `Value for "${modifier}" should be a string` + ); + return trimPrefix(classes, modifier + ":"); + }); + } + + throw new Error("Invalid argument type"); +} + +const definePlugin = + (fn: (runtime: typeof babel) => babel.Visitor) => + (runtime: typeof babel) => { + const plugin: babel.PluginObj = { + visitor: fn(runtime), + }; + return plugin as babel.PluginObj; + }; + +const extractJSXContainer = (attr: NonNullable): t.Expression => + attr.type === "JSXExpressionContainer" ? (attr.expression as t.Expression) : attr; + +function matchPath( + nodePath: NodePath, + fns: (dig: (nodePath: NodePath) => void) => babel.Visitor +) { + if (!nodePath.node) return; + const fn = fns(path => matchPath(path, fns))[nodePath.node.type] as any; + fn?.(nodePath); +} + +function getSuffix(add: boolean | undefined, entries: StyleMapEntry[]) { + if (!add) return ""; + + const cacheKey = hash(entries.map(x => x.className).join(",")); + return `?${cacheKey}`; +} + +const trim = (value: string) => value.replace(/\s+/g, " ").trim(); +const trimPrefix = (cls: string, prefix: string) => + trim(cls) + .split(" ") + .map(value => prefix + value); + +const flatMapEntries = ( + map: Record, + fn: (value: V, key: K) => R[] +): R[] => Object.entries(map).flatMap(([key, value]) => fn(value as V, key as K)); diff --git a/src/css-to-js.ts b/src/css-to-js.ts new file mode 100755 index 0000000..bccc214 --- /dev/null +++ b/src/css-to-js.ts @@ -0,0 +1,289 @@ +// MIT License. Copyright (c) 2017 Brice BERNARD +// https://github.com/brikou/CSS-in-JS-generator/commit/2a887d0d96f1d5044039d0e0457001f0fde0def0 +import { + type AtRule, + type Builder, + type ChildNode, + type Node, + type Root, + type Rule, + parse, +} from "postcss"; +import parseSelector from "postcss-selector-parser"; +import Stringifier from "postcss/lib/stringifier"; +import { camelCase } from "lodash"; + +function getSelectorScope(selector: string): string { + let selectorScope = "root"; + + parseSelector(nodes => { + for (const node of nodes.first.nodes) { + if (node.type === "class") { + selectorScope = node.toString(); + break; + } + } + }).processSync(selector); + + return selectorScope; +} + +function getRequiredScopes(css: string, scope: string, knownScopes: Set) { + const requiredScopes = new Set(); + + parse(css).walkRules(rule => { + parseSelector(nodes => { + nodes.walkClasses(node => { + const selectorScope = getSelectorScope(node.toString()); + if (selectorScope === scope) return; + + if (knownScopes.has(selectorScope)) { + requiredScopes.add(selectorScope); + } + }); + }).processSync(rule.selector); + }); + + return requiredScopes; +} + +function isNode(node: Node | undefined, type: "rule"): node is Rule; +function isNode(node: Node | undefined, type: "atrule"): node is AtRule; +function isNode(node: Node | undefined, type: "root"): node is Root; +function isNode(node: Node | undefined, type: string): boolean { + return node?.type === type; +} + +function addAll(set: Set, values: Iterable) { + for (const value of values) { + set.add(value); + } +} + +function getNodeScopes(node: Node) { + const nodeScopes = new Set(); + + if ( + isNode(node, "rule") && + (!isNode(node.parent, "atrule") || !/keyframes/.test(node.parent.name)) + ) { + addAll(nodeScopes, node.selectors.map(getSelectorScope)); + } else if (isNode(node, "atrule") && !node.name.endsWith("keyframes")) { + node.walkRules(rule => { + addAll(nodeScopes, rule.selectors.map(getSelectorScope)); + }); + } + + if (!nodeScopes.size) { + nodeScopes.add("root"); + } + + return nodeScopes; +} + +function stringify(css: string, builder: Builder): void { + new Stringifier(builder).stringify(parse(css)); +} + +function getCssIndexedByScope(css: string) { + const cssIndexedByScope = new Map(); + const scopesStack = [new Set(["root"])]; + + stringify(css, (output, node, flag) => { + if (flag === "start" && node) { + scopesStack.push(getNodeScopes(node)); + } + + if (flag === "end") { + output += "\n"; + } + + for (const scope of scopesStack.at(-1)!) { + if (!cssIndexedByScope.has(scope)) { + cssIndexedByScope.set(scope, ""); + } + + if ( + flag === "start" && + isNode(node, "rule") && + (!isNode(node.parent, "atrule") || !node.parent.name.endsWith("keyframes")) + ) { + output = `${node.selectors + .filter(selector => getSelectorScope(selector) === scope) + .join(", ")} {`; + } + + cssIndexedByScope.set(scope, cssIndexedByScope.get(scope) + output); + } + + if (flag === "end") { + scopesStack.pop(); + } + }); + + return cssIndexedByScope; +} + +function convertSelectorForEmotion( + selector: string, + scope: string, + knownScopes: Set, + mapClassNames: (className: string) => string +): string { + return parseSelector(nodes => { + nodes.first.walkClasses(node => { + if (node.toString() === scope) { + node.toString = () => "&"; + } else if (knownScopes.has(node.toString())) { + node.toString = () => `.\${${mapClassNames(node.value)}}`; + } + }); + }).processSync(selector); +} + +const convertScopeToModuleName = (scope: string) => + camelCase(scope) + .replace(/^(\d)/, "_$1") + .replace( + /^(break|case|catch|continue|debugger|default|delete|do|else|finally|for|function|if|in|instanceof|new|return|switch|this|throw|try|typeof|var|void|while|with)$/, + "_$1" + ); + +function convertScopedCssForEmotion( + scopedCss: string, + scope: string, + knownScopes: Set, + mapClassNames: (className: string) => string +): string { + let scopedCssForEmotion = ""; + + stringify(scopedCss, (output, node, flag) => { + if ((flag === "start" || flag === "end") && isNode(node, "rule")) { + if (node.selector === scope) { + if (isNode(node.parent, "root")) { + return; + } else if (flag === "start") { + output = "& {"; + } + } else if (flag === "start") { + const selectors = new Set( + node.selectors.map(selector => + convertSelectorForEmotion(selector, scope, knownScopes, mapClassNames) + ) + ); + + // TODO remove join usage once https://github.com/prettier/prettier/issues/2883 is resolved + output = `${[...selectors].join(", ")} {`; + } + } + + scopedCssForEmotion += output; + }); + + return scopedCssForEmotion.replace(/\\/g, "\\\\").replace(/`/g, "\\`"); +} + +export function convertCssToJS( + css: string, + mapClassNames: (className: string) => string = convertScopeToModuleName +): string { + let cssForEmotion = ""; + + const cssIndexedByScope = getCssIndexedByScope(css); + + if (cssIndexedByScope.has("root") && cssIndexedByScope.get("root")!.trim()) { + cssForEmotion += 'import { injectGlobal } from "@emotion/css";\n'; + } + + const knownScopes = new Set(cssIndexedByScope.keys()); + + const collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: "base", + }); + + const sortedKnownScopes = [...knownScopes] + .sort((scopeA, scopeB) => (scopeA === "root" ? -1 : collator.compare(scopeA, scopeB))) + .reduce((previousSortedKnownScopes, knownScope) => { + for (const requiredScope of getRequiredScopes( + cssIndexedByScope.get(knownScope)!, + knownScope, + knownScopes + )) { + previousSortedKnownScopes.add(requiredScope); + } + previousSortedKnownScopes.add(knownScope); + return previousSortedKnownScopes; + }, new Set()); + + for (const scope of sortedKnownScopes) { + cssForEmotion += "\n"; + + const convertedScopedCssForEmotion = convertScopedCssForEmotion( + cssIndexedByScope.get(scope)!, + scope, + knownScopes, + mapClassNames + ).trimEnd(); + + if (!convertedScopedCssForEmotion.trim()) continue; + + cssForEmotion += + scope === "root" + ? `injectGlobal\`${convertedScopedCssForEmotion}\n\`;\n` + : `\nexport const ${mapClassNames( + scope + )} = ${JSON.stringify(asJSObject(convertedScopedCssForEmotion), null, 2)};\n`; + } + + return cssForEmotion.trim(); +} + +const candidates = new Set(["fontSize"]); +function simplifyValue(propName: string, value: string) { + const num = value.match(/^(\d+(\.\d+)?)px$/)?.[1]; + if (num != null && candidates.has(propName)) { + return parseFloat(num); + } + return value; +} + +function asJSObject(inputCssText: string) { + const css = parse(`a{${inputCssText}}`); + const result: Record = {}; + if (css.nodes.length !== 1) { + throw new Error("Expected exactly one root node"); + } + + const node = css.first!; + if (node.type !== "rule") return; + + function walk(collect: Record, node: ChildNode) { + switch (node.type) { + case "atrule": + const obj = (collect[`@${node.name} ${node.params}`] ??= {}); + node.each(child => { + walk(obj, child); + }); + break; + + case "decl": + const propName = node.prop.replace(/(-.)/g, v => v[1].toUpperCase()); + collect[propName] = simplifyValue(propName, node.value); + break; + + case "rule": + node.each(declaration => { + walk(collect, declaration); + }); + break; + + case "comment": + break; + } + } + + walk(result, node); + + return result as React.CSSProperties; +} diff --git a/src/esbuild-babel.ts b/src/esbuild-babel.ts index 9711da0..ac5ff34 100644 --- a/src/esbuild-babel.ts +++ b/src/esbuild-babel.ts @@ -19,7 +19,7 @@ export const babelPlugin = ({ }): esbuild.Plugin => ({ name: "babel-plugin", setup(build) { - build.onLoad({ filter }, ({ path }) => { + build.onLoad({ filter, namespace: "file" }, ({ path }) => { const load = once(() => readFileSync(path, "utf-8")); const plugins = Array.isArray(getPlugins) ? getPlugins diff --git a/src/esbuild-postcss.ts b/src/esbuild-postcss.ts index 96ed10d..a57e1fd 100644 --- a/src/esbuild-postcss.ts +++ b/src/esbuild-postcss.ts @@ -1,16 +1,25 @@ import { dirname, join } from "node:path"; import type * as esbuild from "esbuild"; import { CssSyntaxError } from "postcss"; -import { type Compile, type StyleMap, pkgName, toCSSText } from "./shared"; +import { type Compile, type StyleMap, pkgName } from "./shared"; +import type { BuildStyleFile } from "./index"; const PLUGIN_NAME = "tailwind"; const ESBUILD_NAMESPACE = "babel-tailwind"; -export const esbuildPlugin = (styleMap: StyleMap, compile: Compile): esbuild.Plugin => ({ +export const esbuildPlugin = ({ + styleMap, + compile, + buildStyleFile, +}: { + styleMap: StyleMap; + compile: Compile; + buildStyleFile: BuildStyleFile; +}): esbuild.Plugin => ({ name: PLUGIN_NAME, setup(build) { - build.onResolve({ filter: /^tailwind:.+\.css$/ }, ({ path, importer }) => { + build.onResolve({ filter: /^tailwind:.+\.\w+$/ }, ({ path, importer }) => { const resolved = join(dirname(importer), path.replace(/^tailwind:/, "")); if (styleMap.has(resolved)) { return { @@ -38,11 +47,8 @@ export const esbuildPlugin = (styleMap: StyleMap, compile: Compile): esbuild.Plu const styles = styleMap.get(path)!; try { - const result = await compile(toCSSText(styles)); - return { - contents: result, - loader: "css", - }; + const [loader, contents] = await buildStyleFile(path); + return { contents, loader }; } catch (e) { if (e instanceof CssSyntaxError) { const lines = e.source!.split("\n"); diff --git a/src/index.test.ts b/src/index.test.ts index da1f0ac..cccf6e1 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,28 +1,10 @@ /* eslint-disable unicorn/string-content */ -import { promises as fs } from "node:fs"; -import { resolve } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import * as esbuild from "esbuild"; -import dedent from "dedent"; -import { name } from "../package.json" with { type: "json" }; -import { - type TailwindPluginOptions, - babelPlugin, - createPostCSS, - getClassName, - getTailwindPlugins, -} from "./index"; - -const folder = resolve(import.meta.dirname, "temp"); +import { describe, expect, it } from "vitest"; +import { getClassName } from "./index"; +import { getBuild } from "./__tests__/utils"; describe("babel-tailwind", () => { - beforeEach(async () => { - await fs.mkdir(folder, { recursive: true }); - }); - - afterEach(async () => { - await fs.rm(folder, { recursive: true, force: true }); - }); + const compileESBuild = getBuild("main"); it("supports ESBuild", async () => { const { files } = await compileESBuild({ @@ -69,13 +51,13 @@ describe("babel-tailwind", () => { clsx: "emotion", expectFiles: 2, javascript: /* tsx */ ` - export function Hello() { - return ( -
- Hello, world! -
- ); - } + export function Hello() { + return ( +
+ Hello, world! +
+ ); + } `, }); @@ -107,37 +89,6 @@ describe("babel-tailwind", () => { }); }); - it("reports errors with correct position", async () => { - try { - await compileESBuild({ - clsx: "emotion", - jsxAttributeAction: "preserve", - esbuild: { - logLevel: "silent", - }, - javascript: /* tsx */ ` - export function Hello() { - return ( -
- Hello, world! -
- ); - } - `, - }); - throw new Error("Expected an error"); - } catch (e) { - expect(e.errors).toHaveLength(1); - const [error] = e.errors; - expect(error.location).toMatchObject({ - column: 14, - length: 12, - line: 3, - lineText: '
', - }); - } - }); - it("supports custom jsxAttributeName", async () => { const { files } = await compileESBuild({ clsx: "emotion", @@ -159,51 +110,6 @@ describe("babel-tailwind", () => { expect(files.css.text).toMatch(`.${clsName} {\n text-align: center;\n}`); }); - it("supports grouped tw", async () => { - const { files } = await compileESBuild({ - clsx: "emotion", - expectFiles: 2, - javascript: /* tsx */ ` - export default tw("text-sm", \`flex\`, { - "group-hover": "text-center", - "[&>div]": \`font-semibold\`, - data: { - "name='hello'": "text-right", - nested: { - true: "border", - } - }, - }) - `, - }); - - const clsName = getClassName( - "text-sm flex group-hover:text-center [&>div]:font-semibold data-[name='hello']:text-right data-[nested=true]:border" - ); - expect(files.js.text).toContain(`= "${clsName}"`); - expect(files.css.text).toMatch( - [ - `.${clsName} {`, - " display: flex;", - " font-size: 0.875rem;", - " line-height: 1.25rem;", - "}", - `.group:hover .${clsName} {`, - " text-align: center;", - "}", - `.${clsName}[data-nested=true] {`, - " border-width: 1px;", - "}", - `.${clsName}[data-name=hello] {`, - " text-align: right;", - "}", - `.${clsName} > div {`, - " font-weight: 600;", - "}", - ].join("\n") - ); - }); - it("supports grouped array css attribute", async () => { const { files } = await compileESBuild({ clsx: "emotion", @@ -252,76 +158,4 @@ describe("babel-tailwind", () => { expect(files.js.text).toContain(`className: isCenter ? "${clsName}" : void 0`); expect(files.css.text).toMatch(`.${clsName} {\n text-align: center;\n}`); }); - - it("supports importing tailwind/base", async () => { - const postcss = createPostCSS({ - tailwindConfig: {}, - postCSSPlugins: [], - }); - const base = await postcss("@tailwind base;"); - const { files } = await compileESBuild({ - clsx: "emotion", - expectFiles: 2, - javascript: /* tsx */ ` - import "${name}/base"; - `, - }); - - expect(files.js.text).toBe(""); - expect(minCSS(files.css.text)).toContain(minCSS(base)); - }); }); - -async function write(path: string, content: string) { - const resolved = resolve(folder, path); - await fs.writeFile(resolved, content); - return resolved; -} - -const minCSS = (text: string) => - esbuild.transformSync(text, { minify: true, loader: "css" }).code; - -const findByExt = (outputFiles: esbuild.OutputFile[], ext: string) => - outputFiles.find(file => file.path.endsWith(ext))!; - -async function compileESBuild({ - javascript, - esbuild: esbuildOptions, - expectFiles, - ...options -}: Omit & { - esbuild?: esbuild.BuildOptions; - javascript: string; - expectFiles?: number; -}) { - const tailwind = getTailwindPlugins({ - tailwindConfig: {}, - macroFunction: "tw", - ...options, - }); - const result = await esbuild.build({ - bundle: true, - write: false, - external: ["react/jsx-runtime", "@emotion/css", "clsx"], - outdir: "dist", - format: "esm", - entryPoints: [await write("index.tsx", dedent(javascript))], - plugins: [babelPlugin({ plugins: [tailwind.babel()] }), tailwind.esbuild()], - ...esbuildOptions, - }); - - const { errors, warnings, outputFiles } = result; - expect(errors).toHaveLength(0); - expect(warnings).toHaveLength(0); - - if (expectFiles != null) { - expect(outputFiles).toHaveLength(expectFiles); - } - - return { - outputFiles: outputFiles!, - files: new Proxy({} as Record, { - get: (_, ext: string) => findByExt(outputFiles!, ext), - }), - }; -} diff --git a/src/index.ts b/src/index.ts index 96851fe..dd5ffd0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,11 +7,15 @@ import { type ClassNameCollector, babelTailwind } from "./babel-tailwind"; import { esbuildPlugin } from "./esbuild-postcss"; import { vitePlugin } from "./vite-plugin"; import { type StyleMap, createPostCSS } from "./shared"; +import { convertCssToJS } from "./css-to-js"; export { babelPlugin } from "./esbuild-babel"; export { createPostCSS } from "./shared"; type GetClassName = (className: string) => string; +export type BuildStyleFile = ( + path: string +) => Promise; interface RecursiveStringObject { [modifier: string]: string | RecursiveStringObject; @@ -20,7 +24,7 @@ interface RecursiveStringObject { export type CSSAttributeValue = string | (string | RecursiveStringObject)[]; /** - * Tagged template macro function for Tailwind classes + * Tagged template macro function combining Tailwind classes * @example "tw" => tw`p-2 text-center` */ export interface TailwindFunction { @@ -28,6 +32,15 @@ export interface TailwindFunction { (...args: (string | RecursiveStringObject)[]): string; } +/** + * Tagged template macro function compiling Tailwind styles + * @example "tws" => tws`p-2 text-center` // { padding: 2, textAlign: "center" } + */ +export interface TailwindStyleFunction { + (strings: TemplateStringsArray): Output; + (...args: (string | RecursiveStringObject)[]): Output; +} + export interface TailwindPluginOptions { /** * Tailwind CSS configuration @@ -62,14 +75,21 @@ export interface TailwindPluginOptions { jsxAttributeAction?: "delete" | "preserve" | ["rename", string]; /** - * Template macro function to use for Tailwind classes - * @default "tw" + * Template macro function to combine Tailwind classes * @example * declare const tw: TailwindFunction; * "tw" => tw`p-2 text-center` */ macroFunction?: string | undefined; + /** + * Template macro function to compile Tailwind classes + * @example + * declare const tws: TailwindStyleFunction; + * "tws" => tws`p-2 text-center` // { padding: 2, textAlign: "center" } + */ + macroStyleFunction?: string | undefined; + /** * The prefix to use for the generated class names. * @default className => `tw-${hash(className)}` @@ -108,6 +128,7 @@ export type ResolveTailwindOptions = SetRequired< | "styleMap" | "tailwindConfig" | "macroFunction" + | "macroStyleFunction" >; /** @@ -140,6 +161,7 @@ export function getTailwindPlugins(options: TailwindPluginOptions) { postCSSPlugins: [], styleMap: new Map(), macroFunction: undefined, + macroStyleFunction: undefined, tailwindConfig: {}, ...options, }; @@ -149,12 +171,27 @@ export function getTailwindPlugins(options: TailwindPluginOptions) { const { styleMap } = resolvedOptions; const compile = options.compile ?? memoize(getCompiler()); + const buildStyleFile: BuildStyleFile = async path => { + const styles = styleMap.get(path)!; + const compiled = await compile( + styles.map(({ className, key }) => `.${key} {\n @apply ${className}\n}`).join("\n") + ); + if (path.endsWith(".css")) { + return ["css", compiled] as const; + } else if (path.endsWith(".js")) { + const js = convertCssToJS(compiled, x => x.slice(1)); + return ["js", js] as const; + } else { + throw new Error("Unknown file extension"); + } + }; + return { compile, babel: (onCollect?: ClassNameCollector) => babelTailwind(resolvedOptions, onCollect), - esbuild: () => esbuildPlugin(styleMap, compile), + esbuild: () => esbuildPlugin({ styleMap, compile, buildStyleFile }), /** Requires `options.vite` to be `true`. */ - vite: () => vitePlugin(styleMap, compile), + vite: () => vitePlugin({ styleMap, compile, buildStyleFile }), styleMap, options, getCompiler, diff --git a/src/shared.ts b/src/shared.ts index 59441eb..1b42742 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -52,3 +52,9 @@ export function toCSSText(tailwindMap: StyleMapEntry[]) { .map(({ className, key }) => `.${key} {\n @apply ${className}\n}`) .join("\n"); } + +export function toJSText(tailwindMap: StyleMapEntry[]) { + return tailwindMap + .map(({ className, key }) => `"${key}": "${className}"`) + .join(",\n "); +} diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts index 64f1672..10e7177 100644 --- a/src/vite-plugin.ts +++ b/src/vite-plugin.ts @@ -1,10 +1,19 @@ import { dirname, join } from "node:path"; import type * as vite from "vite"; -import { type Compile, type StyleMap, pkgName, toCSSText } from "./shared"; +import { type Compile, type StyleMap, pkgName } from "./shared"; +import type { BuildStyleFile } from "./index"; const ROLLUP_PREFIX = "\0tailwind:"; -export const vitePlugin = (styleMap: StyleMap, compile: Compile): vite.Plugin => ({ +export const vitePlugin = ({ + styleMap, + compile, + buildStyleFile, +}: { + styleMap: StyleMap; + compile: Compile; + buildStyleFile: BuildStyleFile; +}): vite.Plugin => ({ name: "tailwind", resolveId(id, importer) { if (id === `${pkgName}/base`) { @@ -35,7 +44,7 @@ export const vitePlugin = (styleMap: StyleMap, compile: Compile): vite.Plugin => const name = resolved.split("?")[0]; if (styleMap.has(name)) { - return await compile(toCSSText(styleMap.get(name)!)); + return (await buildStyleFile(name))[1]; } } },