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