Add style object support

This commit is contained in:
Alex 2024-07-02 21:09:10 -04:00
parent 835c5b7810
commit 398f2a7c69
17 changed files with 896 additions and 435 deletions

View File

@ -1,4 +1,3 @@
{
"editor.formatOnSave": true,
"eslint.runtime": "node"
}
"editor.formatOnSave": true
}

View File

@ -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",

166
pnpm-lock.yaml generated
View File

@ -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

View File

@ -28,6 +28,7 @@ await Promise.all([
build({
...tsupConfig,
entry: ["src/index.ts"],
external: ["postcss-selector-parser", "postcss"],
}),
Bun.write(
"dist/package.json",

View File

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

View File

@ -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 (
<div css="text-center2 m-0">
Hello, world!
</div>
);
}
`,
});
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: ' <div css="text-center2 m-0">',
});
}
});
});

View File

@ -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 (
<div style={tws\`p-2 text-center\`}>
Hello, world!
</div>
);
}
`,
});
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}`);
});
});

51
src/__tests__/tw.test.ts Normal file
View File

@ -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")
);
});
});

75
src/__tests__/utils.ts Normal file
View File

@ -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<TailwindPluginOptions, "compile"> & {
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<string, esbuild.OutputFile>, {
get: (_, ext: string) => findByExt(outputFiles!, ext),
}),
};
};
}

View File

@ -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 =
<T>(fn: (runtime: typeof babel) => babel.Visitor<babel.PluginPass & T>) =>
(runtime: typeof babel) => {
const plugin: babel.PluginObj<babel.PluginPass & T> = {
visitor: fn(runtime),
};
return plugin as babel.PluginObj;
};
const extractJSXContainer = (attr: NonNullable<t.JSXAttribute["value"]>): t.Expression =>
attr.type === "JSXExpressionContainer" ? (attr.expression as t.Expression) : attr;
function matchPath(
nodePath: NodePath<t.Node | null | undefined>,
fns: (dig: (nodePath: NodePath<t.Node | null | undefined>) => 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<string, StyleMapEntry>;
}
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 = <K extends string | number, V, R>(
map: Record<K, V>,
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<typeof getState>;
function getState(
path: NodePath<t.Program>,
state: babel.PluginPass,
t: typeof babel.types
) {
let cx: t.Identifier;
let styleImport: t.Identifier;
const cssMap = new Map<string, StyleMapEntry>();
const jsMap = new Map<string, StyleMapEntry>();
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<string, string | object>,
(cls, key) =>
typeof cls === "string"
? trimPrefix(cls, `${modifier}-[${key}]:`)
: flatMapEntries(cls as Record<string, string>, (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<BabelPluginState>(({ 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<string, string | object>, (cls, key) =>
typeof cls === "string"
? trimPrefix(cls, `${modifier}-[${key}]:`)
: flatMapEntries(cls as Record<string, string>, (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 =
<T>(fn: (runtime: typeof babel) => babel.Visitor<babel.PluginPass & T>) =>
(runtime: typeof babel) => {
const plugin: babel.PluginObj<babel.PluginPass & T> = {
visitor: fn(runtime),
};
return plugin as babel.PluginObj;
};
const extractJSXContainer = (attr: NonNullable<t.JSXAttribute["value"]>): t.Expression =>
attr.type === "JSXExpressionContainer" ? (attr.expression as t.Expression) : attr;
function matchPath(
nodePath: NodePath<t.Node | null | undefined>,
fns: (dig: (nodePath: NodePath<t.Node | null | undefined>) => 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 = <K extends string | number, V, R>(
map: Record<K, V>,
fn: (value: V, key: K) => R[]
): R[] => Object.entries(map).flatMap(([key, value]) => fn(value as V, key as K));

289
src/css-to-js.ts Executable file
View File

@ -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<string>) {
const requiredScopes = new Set<string>();
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<T>(set: Set<T>, values: Iterable<T>) {
for (const value of values) {
set.add(value);
}
}
function getNodeScopes(node: Node) {
const nodeScopes = new Set<string>();
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<string, string>();
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<string>,
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<string>,
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<string>());
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<string, string> = {};
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<string, any>, 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;
}

View File

@ -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

View File

@ -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");

View File

@ -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 (
<div className="text-center" css="text-center">
Hello, world!
</div>
);
}
export function Hello() {
return (
<div className="text-center" css="text-center">
Hello, world!
</div>
);
}
`,
});
@ -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 (
<div css="text-center2 m-0">
Hello, world!
</div>
);
}
`,
});
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: ' <div css="text-center2 m-0">',
});
}
});
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<TailwindPluginOptions, "compile"> & {
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<string, esbuild.OutputFile>, {
get: (_, ext: string) => findByExt(outputFiles!, ext),
}),
};
}

View File

@ -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<readonly ["css", string] | readonly ["js", string]>;
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<Output = React.CSSProperties> {
(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,

View File

@ -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 ");
}

View File

@ -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];
}
}
},