From 4114914eeafcabf54f9e8beae5a503cb52905955 Mon Sep 17 00:00:00 2001 From: Alex <8125011+alex-kinokon@users.noreply.github.com> Date: Sun, 7 Apr 2024 03:13:05 -0400 Subject: [PATCH] Improve error handling --- .eslintrc.js | 2 +- package.json | 7 +- pnpm-lock.yaml | 142 +++++++++++-------- src/babel-tailwind.ts | 211 +++++++++++++++++++++++++++ src/esbuild-postcss.ts | 90 ++++++++++++ src/index.test.ts | 152 +++++++++++++------- src/index.ts | 315 ++--------------------------------------- src/shared.ts | 56 ++++++++ src/vite-plugin.ts | 40 ++++++ 9 files changed, 591 insertions(+), 424 deletions(-) create mode 100644 src/babel-tailwind.ts create mode 100644 src/esbuild-postcss.ts create mode 100644 src/shared.ts create mode 100644 src/vite-plugin.ts diff --git a/.eslintrc.js b/.eslintrc.js index f523452..7cc3800 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,5 +2,5 @@ const { extendConfig } = require("@aet/eslint-rules"); module.exports = extendConfig({ - plugins: ["react"], + plugins: ["react", "unicorn"], }); diff --git a/package.json b/package.json index 2d7f5c0..eb8e0a9 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,16 @@ "dist" ], "devDependencies": { - "@aet/eslint-rules": "^0.0.21", + "@aet/eslint-rules": "^0.0.22", "@types/babel__core": "^7.20.5", "@types/bun": "^1.0.12", + "@types/dedent": "^0.7.2", "@types/lodash": "^4.17.0", "@types/node": "^20.12.5", + "dedent": "^1.5.1", "esbuild": "^0.20.2", "esbuild-register": "^3.5.0", + "eslint": "^8.57.0", "prettier": "^3.2.5", "tailwindcss": "^3.4.3", "tsup": "^8.0.2", @@ -43,4 +46,4 @@ "singleQuote": false, "trailingComma": "es5" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c1c714..f337982 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,26 +20,35 @@ dependencies: devDependencies: '@aet/eslint-rules': - specifier: ^0.0.19 - version: 0.0.19(eslint@8.57.0)(typescript@5.4.4) + specifier: ^0.0.22 + version: 0.0.22(eslint@8.57.0)(typescript@5.4.4) '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 '@types/bun': specifier: ^1.0.12 version: 1.0.12 + '@types/dedent': + specifier: ^0.7.2 + version: 0.7.2 '@types/lodash': specifier: ^4.17.0 version: 4.17.0 '@types/node': specifier: ^20.12.5 version: 20.12.5 + dedent: + specifier: ^1.5.1 + version: 1.5.1 esbuild: specifier: ^0.20.2 version: 0.20.2 esbuild-register: specifier: ^3.5.0 version: 3.5.0(esbuild@0.20.2) + eslint: + specifier: ^8.57.0 + version: 8.57.0 prettier: specifier: ^3.2.5 version: 3.2.5 @@ -66,18 +75,18 @@ packages: engines: {node: '>=0.10.0'} dev: true - /@aet/eslint-rules@0.0.19(eslint@8.57.0)(typescript@5.4.4): - resolution: {integrity: sha512-RO9JBZcdY2HVvWPvqlob2yNwszXwOPUL81uXCg3IrjDi7Ka48zWsfEyMpF/w/3jNgwhYxDBLTJAhHABuJ2LtXQ==} + /@aet/eslint-rules@0.0.22(eslint@8.57.0)(typescript@5.4.4): + resolution: {integrity: sha512-PLAnaYlb6GdG6gKjQP5B8XckOa7driZL1rbw51wGTYTl/0Etqu5R/u4TkQSYePZab7xCIrJuPSC3PsBEhI5pvQ==} peerDependencies: - eslint: ^8.53.0 - typescript: ^5.2.2 + eslint: ^8.57.0 + typescript: ^5.4.4 dependencies: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@types/eslint': 8.56.6 - '@typescript-eslint/eslint-plugin': 7.4.0(@typescript-eslint/parser@7.4.0)(eslint@8.57.0)(typescript@5.4.4) - '@typescript-eslint/parser': 7.4.0(eslint@8.57.0)(typescript@5.4.4) - '@typescript-eslint/type-utils': 7.4.0(eslint@8.57.0)(typescript@5.4.4) - '@typescript-eslint/utils': 7.4.0(eslint@8.57.0)(typescript@5.4.4) + '@types/eslint': 8.56.7 + '@typescript-eslint/eslint-plugin': 7.5.0(@typescript-eslint/parser@7.5.0)(eslint@8.57.0)(typescript@5.4.4) + '@typescript-eslint/parser': 7.5.0(eslint@8.57.0)(typescript@5.4.4) + '@typescript-eslint/type-utils': 7.5.0(eslint@8.57.0)(typescript@5.4.4) + '@typescript-eslint/utils': 7.5.0(eslint@8.57.0)(typescript@5.4.4) aria-query: 5.3.0 axe-core: 4.9.0 axobject-query: 4.0.0 @@ -90,10 +99,10 @@ packages: eslint-config-prettier: 9.1.0(eslint@8.57.0) eslint-define-config: 1.24.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.4.0)(eslint-import-resolver-node@0.3.9)(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.5.0)(eslint-import-resolver-node@0.3.9)(eslint@8.57.0) eslint-plugin-es-x: 7.6.0(eslint@8.57.0) - eslint-plugin-jsdoc: 48.2.2(eslint@8.57.0) - eslint-plugin-unicorn: 51.0.1(eslint@8.57.0) + eslint-plugin-jsdoc: 48.2.3(eslint@8.57.0) + eslint-plugin-unicorn: 52.0.0(eslint@8.57.0) estraverse: 5.3.0 fast-glob: 3.3.2 get-tsconfig: 4.7.3 @@ -789,7 +798,7 @@ packages: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} dependencies: - '@humanwhocodes/object-schema': 2.0.2 + '@humanwhocodes/object-schema': 2.0.3 debug: 4.3.4 minimatch: 3.1.2 transitivePeerDependencies: @@ -801,8 +810,8 @@ packages: engines: {node: '>=12.22'} dev: true - /@humanwhocodes/object-schema@2.0.2: - resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} + /@humanwhocodes/object-schema@2.0.3: + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} dev: true /@isaacs/cliui@8.0.2: @@ -1036,8 +1045,12 @@ packages: bun-types: 1.0.36 dev: true - /@types/eslint@8.56.6: - resolution: {integrity: sha512-ymwc+qb1XkjT/gfoQwxIeHZ6ixH23A+tCT2ADSA/DPVKzAjwYkTXBMCQ/f6fe4wEa85Lhp26VPeUxI7wMhAi7A==} + /@types/dedent@0.7.2: + resolution: {integrity: sha512-kRiitIeUg1mPV9yH4VUJ/1uk2XjyANfeL8/7rH1tsjvHeO9PJLBHJIYsFWmAvmGj5u8rj+1TZx7PZzW2qLw3Lw==} + dev: true + + /@types/eslint@8.56.7: + resolution: {integrity: sha512-SjDvI/x3zsZnOkYZ3lCt9lOZWZLB2jIlNKz+LBgCtDurK0JZcwucxYHn1w2BJkD34dgX9Tjnak0txtq4WTggEA==} dependencies: '@types/estree': 1.0.5 '@types/json-schema': 7.0.15 @@ -1081,8 +1094,8 @@ packages: '@types/node': 20.12.5 dev: true - /@typescript-eslint/eslint-plugin@7.4.0(@typescript-eslint/parser@7.4.0)(eslint@8.57.0)(typescript@5.4.4): - resolution: {integrity: sha512-yHMQ/oFaM7HZdVrVm/M2WHaNPgyuJH4WelkSVEWSSsir34kxW2kDJCxlXRhhGWEsMN0WAW/vLpKfKVcm8k+MPw==} + /@typescript-eslint/eslint-plugin@7.5.0(@typescript-eslint/parser@7.5.0)(eslint@8.57.0)(typescript@5.4.4): + resolution: {integrity: sha512-HpqNTH8Du34nLxbKgVMGljZMG0rJd2O9ecvr2QLYp+7512ty1j42KnsFwspPXg1Vh8an9YImf6CokUBltisZFQ==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: '@typescript-eslint/parser': ^7.0.0 @@ -1093,11 +1106,11 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 7.4.0(eslint@8.57.0)(typescript@5.4.4) - '@typescript-eslint/scope-manager': 7.4.0 - '@typescript-eslint/type-utils': 7.4.0(eslint@8.57.0)(typescript@5.4.4) - '@typescript-eslint/utils': 7.4.0(eslint@8.57.0)(typescript@5.4.4) - '@typescript-eslint/visitor-keys': 7.4.0 + '@typescript-eslint/parser': 7.5.0(eslint@8.57.0)(typescript@5.4.4) + '@typescript-eslint/scope-manager': 7.5.0 + '@typescript-eslint/type-utils': 7.5.0(eslint@8.57.0)(typescript@5.4.4) + '@typescript-eslint/utils': 7.5.0(eslint@8.57.0)(typescript@5.4.4) + '@typescript-eslint/visitor-keys': 7.5.0 debug: 4.3.4 eslint: 8.57.0 graphemer: 1.4.0 @@ -1110,8 +1123,8 @@ packages: - supports-color dev: true - /@typescript-eslint/parser@7.4.0(eslint@8.57.0)(typescript@5.4.4): - resolution: {integrity: sha512-ZvKHxHLusweEUVwrGRXXUVzFgnWhigo4JurEj0dGF1tbcGh6buL+ejDdjxOQxv6ytcY1uhun1p2sm8iWStlgLQ==} + /@typescript-eslint/parser@7.5.0(eslint@8.57.0)(typescript@5.4.4): + resolution: {integrity: sha512-cj+XGhNujfD2/wzR1tabNsidnYRaFfEkcULdcIyVBYcXjBvBKOes+mpMBP7hMpOyk+gBcfXsrg4NBGAStQyxjQ==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: eslint: ^8.56.0 @@ -1120,10 +1133,10 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/scope-manager': 7.4.0 - '@typescript-eslint/types': 7.4.0 - '@typescript-eslint/typescript-estree': 7.4.0(typescript@5.4.4) - '@typescript-eslint/visitor-keys': 7.4.0 + '@typescript-eslint/scope-manager': 7.5.0 + '@typescript-eslint/types': 7.5.0 + '@typescript-eslint/typescript-estree': 7.5.0(typescript@5.4.4) + '@typescript-eslint/visitor-keys': 7.5.0 debug: 4.3.4 eslint: 8.57.0 typescript: 5.4.4 @@ -1131,16 +1144,16 @@ packages: - supports-color dev: true - /@typescript-eslint/scope-manager@7.4.0: - resolution: {integrity: sha512-68VqENG5HK27ypafqLVs8qO+RkNc7TezCduYrx8YJpXq2QGZ30vmNZGJJJC48+MVn4G2dCV8m5ZTVnzRexTVtw==} + /@typescript-eslint/scope-manager@7.5.0: + resolution: {integrity: sha512-Z1r7uJY0MDeUlql9XJ6kRVgk/sP11sr3HKXn268HZyqL7i4cEfrdFuSSY/0tUqT37l5zT0tJOsuDP16kio85iA==} engines: {node: ^18.18.0 || >=20.0.0} dependencies: - '@typescript-eslint/types': 7.4.0 - '@typescript-eslint/visitor-keys': 7.4.0 + '@typescript-eslint/types': 7.5.0 + '@typescript-eslint/visitor-keys': 7.5.0 dev: true - /@typescript-eslint/type-utils@7.4.0(eslint@8.57.0)(typescript@5.4.4): - resolution: {integrity: sha512-247ETeHgr9WTRMqHbbQdzwzhuyaJ8dPTuyuUEMANqzMRB1rj/9qFIuIXK7l0FX9i9FXbHeBQl/4uz6mYuCE7Aw==} + /@typescript-eslint/type-utils@7.5.0(eslint@8.57.0)(typescript@5.4.4): + resolution: {integrity: sha512-A021Rj33+G8mx2Dqh0nMO9GyjjIBK3MqgVgZ2qlKf6CJy51wY/lkkFqq3TqqnH34XyAHUkq27IjlUkWlQRpLHw==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: eslint: ^8.56.0 @@ -1149,8 +1162,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 7.4.0(typescript@5.4.4) - '@typescript-eslint/utils': 7.4.0(eslint@8.57.0)(typescript@5.4.4) + '@typescript-eslint/typescript-estree': 7.5.0(typescript@5.4.4) + '@typescript-eslint/utils': 7.5.0(eslint@8.57.0)(typescript@5.4.4) debug: 4.3.4 eslint: 8.57.0 ts-api-utils: 1.3.0(typescript@5.4.4) @@ -1159,13 +1172,13 @@ packages: - supports-color dev: true - /@typescript-eslint/types@7.4.0: - resolution: {integrity: sha512-mjQopsbffzJskos5B4HmbsadSJQWaRK0UxqQ7GuNA9Ga4bEKeiO6b2DnB6cM6bpc8lemaPseh0H9B/wyg+J7rw==} + /@typescript-eslint/types@7.5.0: + resolution: {integrity: sha512-tv5B4IHeAdhR7uS4+bf8Ov3k793VEVHd45viRRkehIUZxm0WF82VPiLgHzA/Xl4TGPg1ZD49vfxBKFPecD5/mg==} engines: {node: ^18.18.0 || >=20.0.0} dev: true - /@typescript-eslint/typescript-estree@7.4.0(typescript@5.4.4): - resolution: {integrity: sha512-A99j5AYoME/UBQ1ucEbbMEmGkN7SE0BvZFreSnTd1luq7yulcHdyGamZKizU7canpGDWGJ+Q6ZA9SyQobipePg==} + /@typescript-eslint/typescript-estree@7.5.0(typescript@5.4.4): + resolution: {integrity: sha512-YklQQfe0Rv2PZEueLTUffiQGKQneiIEKKnfIqPIOxgM9lKSZFCjT5Ad4VqRKj/U4+kQE3fa8YQpskViL7WjdPQ==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: typescript: '*' @@ -1173,8 +1186,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/types': 7.4.0 - '@typescript-eslint/visitor-keys': 7.4.0 + '@typescript-eslint/types': 7.5.0 + '@typescript-eslint/visitor-keys': 7.5.0 debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 @@ -1186,8 +1199,8 @@ packages: - supports-color dev: true - /@typescript-eslint/utils@7.4.0(eslint@8.57.0)(typescript@5.4.4): - resolution: {integrity: sha512-NQt9QLM4Tt8qrlBVY9lkMYzfYtNz8/6qwZg8pI3cMGlPnj6mOpRxxAm7BMJN9K0AiY+1BwJ5lVC650YJqYOuNg==} + /@typescript-eslint/utils@7.5.0(eslint@8.57.0)(typescript@5.4.4): + resolution: {integrity: sha512-3vZl9u0R+/FLQcpy2EHyRGNqAS/ofJ3Ji8aebilfJe+fobK8+LbIFmrHciLVDxjDoONmufDcnVSF38KwMEOjzw==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: eslint: ^8.56.0 @@ -1195,9 +1208,9 @@ packages: '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) '@types/json-schema': 7.0.15 '@types/semver': 7.5.8 - '@typescript-eslint/scope-manager': 7.4.0 - '@typescript-eslint/types': 7.4.0 - '@typescript-eslint/typescript-estree': 7.4.0(typescript@5.4.4) + '@typescript-eslint/scope-manager': 7.5.0 + '@typescript-eslint/types': 7.5.0 + '@typescript-eslint/typescript-estree': 7.5.0(typescript@5.4.4) eslint: 8.57.0 semver: 7.6.0 transitivePeerDependencies: @@ -1205,11 +1218,11 @@ packages: - typescript dev: true - /@typescript-eslint/visitor-keys@7.4.0: - resolution: {integrity: sha512-0zkC7YM0iX5Y41homUUeW1CHtZR01K3ybjM1l6QczoMuay0XKtrb93kv95AxUGwdjGr64nNqnOCwmEl616N8CA==} + /@typescript-eslint/visitor-keys@7.5.0: + resolution: {integrity: sha512-mcuHM/QircmA6O7fy6nn2w/3ditQkj+SgtOc8DW3uQ10Yfj42amm2i+6F2K4YAOPNNTmE6iM1ynM6lrSwdendA==} engines: {node: ^18.18.0 || >=20.0.0} dependencies: - '@typescript-eslint/types': 7.4.0 + '@typescript-eslint/types': 7.5.0 eslint-visitor-keys: 3.4.3 dev: true @@ -1593,6 +1606,15 @@ packages: dependencies: ms: 2.1.2 + /dedent@1.5.1: + resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + dev: true + /deep-eql@4.1.3: resolution: {integrity: sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==} engines: {node: '>=6'} @@ -1789,7 +1811,7 @@ packages: - supports-color dev: true - /eslint-module-utils@2.8.1(@typescript-eslint/parser@7.4.0)(eslint-import-resolver-node@0.3.9)(eslint@8.57.0): + /eslint-module-utils@2.8.1(@typescript-eslint/parser@7.5.0)(eslint-import-resolver-node@0.3.9)(eslint@8.57.0): resolution: {integrity: sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==} engines: {node: '>=4'} peerDependencies: @@ -1810,7 +1832,7 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 7.4.0(eslint@8.57.0)(typescript@5.4.4) + '@typescript-eslint/parser': 7.5.0(eslint@8.57.0)(typescript@5.4.4) debug: 3.2.7 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 @@ -1830,8 +1852,8 @@ packages: eslint-compat-utils: 0.5.0(eslint@8.57.0) dev: true - /eslint-plugin-jsdoc@48.2.2(eslint@8.57.0): - resolution: {integrity: sha512-S0Gk+rpT5w/ephKCncUY7kUsix9uE4B9XI8D/fS1/26d8okE+vZsuG1IvIt4B6sJUdQqsnzi+YXfmh+HJG11CA==} + /eslint-plugin-jsdoc@48.2.3(eslint@8.57.0): + resolution: {integrity: sha512-r9DMAmFs66VNvNqRLLjHejdnJtILrt3xGi+Qx0op0oRfFGVpOR1Hb3BC++MacseHx93d8SKYPhyrC9BS7Os2QA==} engines: {node: '>=18'} peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 @@ -1850,8 +1872,8 @@ packages: - supports-color dev: true - /eslint-plugin-unicorn@51.0.1(eslint@8.57.0): - resolution: {integrity: sha512-MuR/+9VuB0fydoI0nIn2RDA5WISRn4AsJyNSaNKLVwie9/ONvQhxOBbkfSICBPnzKrB77Fh6CZZXjgTt/4Latw==} + /eslint-plugin-unicorn@52.0.0(eslint@8.57.0): + resolution: {integrity: sha512-1Yzm7/m+0R4djH0tjDjfVei/ju2w3AzUGjG6q8JnuNIL5xIwsflyCooW5sfBvQp2pMYQFSWWCFONsjCax1EHng==} engines: {node: '>=16'} peerDependencies: eslint: '>=8.56.0' diff --git a/src/babel-tailwind.ts b/src/babel-tailwind.ts new file mode 100644 index 0000000..3e23544 --- /dev/null +++ b/src/babel-tailwind.ts @@ -0,0 +1,211 @@ +import { basename, dirname, extname, join } from "node:path"; +import type babel from "@babel/core"; +import { type NodePath, type types as t } from "@babel/core"; +import type { SourceLocation, StyleMap, StyleMapEntry } from "./shared"; +import { type TailwindPluginOptions, getClassName } from "./index"; + +interface BabelPluginState { + getCx: () => t.Identifier; + sliceText: (node: t.Node) => SourceLocation; + tailwindMap: Map; +} + +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); +} + +export function babelTailwind( + styleMap: StyleMap, + { + clsx, + getClassName: getClass = getClassName, + taggedTemplateName, + jsxAttributeAction = "delete", + jsxAttributeName = "css", + }: TailwindPluginOptions +) { + 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"); + } + } + + return definePlugin(({ types: t }) => ({ + Program: { + enter(path, state) { + let cx: t.Identifier; + 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.getCx = () => { + if (cx == null) { + cx = path.scope.generateUidIdentifier("cx"); + path.node.body.unshift(getClsxImport(t, cx)); + } + return t.cloneNode(cx); + }; + }, + + exit({ node }, { filename, tailwindMap }) { + if (!tailwindMap.size) return; + if (!filename) { + throw new Error("babel: missing state.filename"); + } + + const cssName = basename(filename, extname(filename)) + ".css"; + node.body.unshift( + t.importDeclaration([], t.stringLiteral(`tailwind:./${cssName}`)) + ); + + styleMap.set(join(dirname(filename), cssName), Array.from(tailwindMap.values())); + }, + }, + + TaggedTemplateExpression(path, { tailwindMap, sliceText }) { + if (taggedTemplateName == null) return; + const { node } = path; + + const { + tag, + quasi: { quasis, expressions }, + } = node; + if (!t.isIdentifier(tag, { name: taggedTemplateName })) return; + + if (expressions.length) { + throw new Error(`${taggedTemplateName}\`\` should not contain expressions`); + } + + const value = quasis[0].value.cooked; + if (value) { + const trimmed = value.replace(/\s+/g, " ").trim(); + const className = getClass(trimmed); + tailwindMap.set(className, { + key: className, + className: trimmed, + location: sliceText(node), + }); + path.replaceWith(t.stringLiteral(className)); + } + }, + + JSXAttribute(path, { tailwindMap, sliceText, getCx }) { + const { name } = path.node; + if (name.name !== jsxAttributeName) return; + + const valuePath = path.get("value"); + if (!valuePath.node) return; + + const copy = + jsxAttributeAction === "delete" ? undefined : t.cloneNode(valuePath.node, true); + + const parent = path.parent as t.JSXOpeningElement; + const classNameAttribute = parent.attributes.find( + (attr): attr is t.JSXAttribute => + t.isJSXAttribute(attr) && attr.name.name === "className" + ); + + matchPath(valuePath, go => ({ + StringLiteral(path) { + const { node } = path; + const { value } = node; + + if (value) { + const trimmed = value.replace(/\s+/g, " ").trim(); + const className = getClass(trimmed); + tailwindMap.set(className, { + key: className, + className: trimmed, + location: sliceText(node), + }); + path.replaceWith(t.stringLiteral(className)); + } + }, + JSXExpressionContainer(path) { + go(path.get("expression")); + }, + ConditionalExpression(path) { + go(path.get("consequent")); + go(path.get("alternate")); + }, + LogicalExpression(path) { + go(path.get("right")); + }, + CallExpression(path) { + for (const arg of path.get("arguments")) { + go(arg); + } + }, + })); + + if (classNameAttribute) { + const attrValue = classNameAttribute.value!; + + // If both are string literals, we can merge them directly here + if (t.isStringLiteral(attrValue) && t.isStringLiteral(valuePath.node)) { + attrValue.value += + (attrValue.value.at(-1) === " " ? "" : " ") + valuePath.node.value; + } else { + classNameAttribute.value = t.jsxExpressionContainer( + t.callExpression(getCx(), [ + extractJSXContainer(attrValue), + extractJSXContainer(valuePath.node), + ]) + ); + } + } else { + parent.attributes.push( + t.jsxAttribute(t.jsxIdentifier("className"), valuePath.node) + ); + } + + if (jsxAttributeAction === "delete") { + path.remove(); + } else { + path.node.value = copy!; + if (Array.isArray(jsxAttributeAction) && jsxAttributeAction[0] === "rename") { + path.node.name.name = jsxAttributeAction[1]; + } + } + }, + })); +} diff --git a/src/esbuild-postcss.ts b/src/esbuild-postcss.ts new file mode 100644 index 0000000..750d9c3 --- /dev/null +++ b/src/esbuild-postcss.ts @@ -0,0 +1,90 @@ +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"; + +const PLUGIN_NAME = "tailwind"; +const ESBUILD_NAMESPACE = "babel-tailwind"; + +export const esbuildPlugin = (styleMap: StyleMap, compile: Compile): esbuild.Plugin => ({ + name: PLUGIN_NAME, + + setup(build) { + build.onResolve({ filter: /^tailwind:.+\.css$/ }, ({ path, importer }) => { + const resolved = join(dirname(importer), path.replace(/^tailwind:/, "")); + if (styleMap.has(resolved)) { + return { + path: resolved, + namespace: ESBUILD_NAMESPACE, + }; + } + }); + + build.onResolve({ filter: RegExp(`^${pkgName}/base$`) }, () => ({ + path: "directive:base", + namespace: ESBUILD_NAMESPACE, + })); + + build.onLoad({ filter: /.*/, namespace: ESBUILD_NAMESPACE }, async ({ path }) => { + if (path === "directive:base") { + return { + contents: (await compile("@tailwind base")).css, + loader: "css", + }; + } + + if (!styleMap.has(path)) return; + + const styles = styleMap.get(path)!; + + try { + const result = await compile(toCSSText(styles)); + return { + contents: result.css, + loader: "css", + }; + } catch (e) { + if (e instanceof CssSyntaxError) { + const lines = e.source!.split("\n"); + const cls = lines + .at(e.line! - 2)! + .slice(1, -1) + .trim(); + + const entry = styles.find(s => s.key === cls)!; + if (!entry) { + throw new Error("Could not find entry for CSS"); + } + + const { location: loc } = entry; + const errLoc: Partial = { + file: loc.filename, + line: loc.start.line, + column: loc.start.column, + length: loc.end.column - loc.start.column, + lineText: loc.text, + }; + + const doesNotExist = e.reason.match(/The `(.+)` class does not exist/)?.[1]; + if (doesNotExist) { + const index = loc.text.indexOf(doesNotExist, loc.start.column); + if (index !== -1) { + errLoc.column = index; + errLoc.length = doesNotExist.length; + } + } + + return { + errors: [ + { + text: e.reason, + location: errLoc, + }, + ], + }; + } + throw e; + } + }); + }, +}); diff --git a/src/index.test.ts b/src/index.test.ts index 9143ebe..8f96698 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,7 +1,8 @@ import { promises as fs } from "node:fs"; import { resolve } from "node:path"; -import { type OutputFile, build, transformSync } from "esbuild"; +import { type BuildOptions, type OutputFile, build, transformSync } from "esbuild"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import dedent from "dedent"; import { name } from "../package.json" with { type: "json" }; import { type TailwindPluginOptions, @@ -22,44 +23,10 @@ describe("babel-tailwind", () => { await fs.rm(folder, { recursive: true, force: true }); }); - async function write(path: string, content: string) { - const resolved = resolve(folder, path); - await fs.writeFile(resolved, content); - return resolved; - } - - const minCSS = (text: string) => - transformSync(text, { minify: true, loader: "css" }).code; - - const findByExt = (outputFiles: OutputFile[], ext: string) => - outputFiles.find(file => file.path.endsWith(ext))!; - - async function compileESBuild(options: TailwindPluginOptions, javascript: string) { - const tailwind = getTailwindPlugins({ - tailwindConfig: {}, - ...options, - }); - const result = await build({ - bundle: true, - write: false, - external: ["react/jsx-runtime"], - outdir: "dist", - format: "esm", - entryPoints: [await write("index.tsx", javascript)], - plugins: [babelPlugin({ plugins: [tailwind.babel] }), tailwind.esbuild], - }); - - const { errors, warnings, outputFiles } = result; - expect(errors).toHaveLength(0); - expect(warnings).toHaveLength(0); - - return outputFiles; - } - it("supports ESBuild", async () => { - const outputFiles = await compileESBuild( - { clsx: "emotion" }, - /* tsx */ ` + const outputFiles = await compileESBuild({ + clsx: "emotion", + javascript: /* tsx */ ` export function Hello() { return (
@@ -67,8 +34,8 @@ describe("babel-tailwind", () => {
); } - ` - ); + `, + }); expect(outputFiles).toHaveLength(2); const js = findByExt(outputFiles, ".js"); @@ -80,9 +47,10 @@ describe("babel-tailwind", () => { }); it("does not remove the attribute if `preserveAttribute` is true", async () => { - const outputFiles = await compileESBuild( - { clsx: "emotion", jsxAttributeAction: "preserve" }, - /* tsx */ ` + const outputFiles = await compileESBuild({ + clsx: "emotion", + jsxAttributeAction: "preserve", + javascript: /* tsx */ ` export function Hello() { return (
@@ -90,18 +58,50 @@ describe("babel-tailwind", () => {
); } - ` - ); + `, + }); expect(outputFiles).toHaveLength(2); const js = findByExt(outputFiles, ".js"); expect(js.text).toContain(`css: "text-center"`); }); + 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 outputFiles = await compileESBuild( - { clsx: "emotion", jsxAttributeName: "tw" }, - /* tsx */ ` + const outputFiles = await compileESBuild({ + clsx: "emotion", + jsxAttributeName: "tw", + javascript: /* tsx */ ` export function Hello() { return (
@@ -109,8 +109,8 @@ describe("babel-tailwind", () => {
); } - ` - ); + `, + }); expect(outputFiles).toHaveLength(2); const js = findByExt(outputFiles, ".js"); @@ -124,12 +124,12 @@ describe("babel-tailwind", () => { it("supports importing tailwind/base", async () => { const postcss = createPostCSS({ tailwindConfig: {} }); const base = (await postcss("@tailwind base;")).css; - const outputFiles = await compileESBuild( - { clsx: "emotion" }, - /* tsx */ ` + const outputFiles = await compileESBuild({ + clsx: "emotion", + javascript: /* tsx */ ` import "${name}/base"; - ` - ); + `, + }); expect(outputFiles).toHaveLength(2); const js = findByExt(outputFiles, ".js"); @@ -141,3 +141,45 @@ describe("babel-tailwind", () => { expect(minCSS(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) => + transformSync(text, { minify: true, loader: "css" }).code; + +const findByExt = (outputFiles: OutputFile[], ext: string) => + outputFiles.find(file => file.path.endsWith(ext))!; + +async function compileESBuild({ + javascript, + esbuild, + ...options +}: TailwindPluginOptions & { + esbuild?: BuildOptions; + javascript: string; +}) { + const tailwind = getTailwindPlugins({ + tailwindConfig: {}, + ...options, + }); + const result = await build({ + bundle: true, + write: false, + external: ["react/jsx-runtime"], + outdir: "dist", + format: "esm", + entryPoints: [await write("index.tsx", dedent(javascript))], + plugins: [babelPlugin({ plugins: [tailwind.babel] }), tailwind.esbuild], + ...esbuild, + }); + + const { errors, warnings, outputFiles } = result; + expect(errors).toHaveLength(0); + expect(warnings).toHaveLength(0); + + return outputFiles!; +} diff --git a/src/index.ts b/src/index.ts index 12bb38b..4a2f5cb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,42 +1,15 @@ -import { basename, dirname, extname, join } from "node:path"; import hash from "@emotion/hash"; -import type babel from "@babel/core"; -import type * as vite from "vite"; -import type * as esbuild from "esbuild"; -import { type NodePath, type types as t } from "@babel/core"; -import tailwind, { type Config } from "tailwindcss"; -import postcss from "postcss"; +import type { Config } from "tailwindcss"; +import type postcss from "postcss"; +import { babelTailwind } from "./babel-tailwind"; +import { esbuildPlugin } from "./esbuild-postcss"; +import { vitePlugin } from "./vite-plugin"; +import { type StyleMap, createPostCSS, type tailwindDirectives } from "./shared"; export { babelPlugin } from "./esbuild-babel"; +export { createPostCSS } from "./shared"; -const PLUGIN_NAME = "tailwind"; -const ESBUILD_NAMESPACE = "babel-tailwind"; -const ROLLUP_PREFIX = "\0tailwind:"; - -const { name } = [require][0]( - process.env.BABEL_TAILWIND_BUILD ? "./package.json" : "../package.json" -); - -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); -} +type GetClassName = (className: string) => string; /** * Tagged template macro function for Tailwind classes @@ -44,8 +17,6 @@ function matchPath( */ export type TaggedTailwindFunction = (strings: TemplateStringsArray) => string; -const tailwindDirectives = ["components", "utilities", "variants"] as const; - export interface TailwindPluginOptions { /** * Tailwind CSS configuration @@ -95,280 +66,12 @@ export interface TailwindPluginOptions { clsx: "clsx" | "classnames" | "emotion"; } -type GetClassName = (className: string) => string; - /** * Hashes and prefixes a string of Tailwind class names. * @example getClassName("p-2 text-center") // "tw-1r6fxxz" */ export const getClassName: GetClassName = cls => "tw-" + hash(cls); -interface BabelPluginState { - getCx: () => t.Identifier; - tailwindMap: Map; -} - -function babelTailwind( - styleMap: Map, - { - clsx, - getClassName: getClass = getClassName, - taggedTemplateName, - jsxAttributeAction = "delete", - jsxAttributeName = "css", - }: TailwindPluginOptions -) { - 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"); - } - } - - return definePlugin(({ types: t }) => ({ - Program: { - enter(path, state) { - let cx: t.Identifier; - state.tailwindMap = new Map(); - state.getCx = () => { - if (cx == null) { - cx = path.scope.generateUidIdentifier("cx"); - path.node.body.unshift(getClsxImport(t, cx)); - } - return t.cloneNode(cx); - }; - }, - - exit({ node }, { filename, tailwindMap }) { - if (!tailwindMap.size) return; - if (!filename) { - throw new Error("babel: missing state.filename"); - } - - const cssName = basename(filename, extname(filename)) + ".css"; - node.body.unshift( - t.importDeclaration([], t.stringLiteral(`tailwind:./${cssName}`)) - ); - - const result = Array.from(tailwindMap) - .map(([className, value]) => `.${className} {\n @apply ${value}\n}`) - .join("\n"); - - styleMap.set(join(dirname(filename), cssName), result); - }, - }, - - TaggedTemplateExpression(path, { tailwindMap }) { - if (taggedTemplateName == null) return; - - const { - tag, - quasi: { quasis, expressions }, - } = path.node; - if (!t.isIdentifier(tag, { name: taggedTemplateName })) return; - - if (expressions.length) { - throw new Error(`${taggedTemplateName}\`\` should not contain expressions`); - } - - const value = quasis[0].value.cooked; - if (value) { - const trimmed = value.replace(/\s+/g, " ").trim(); - const className = getClass(trimmed); - tailwindMap.set(className, trimmed); - path.replaceWith(t.stringLiteral(className)); - } - }, - - JSXAttribute(path, { tailwindMap, getCx }) { - const { name } = path.node; - if (name.name !== jsxAttributeName) return; - - const valuePath = path.get("value"); - if (!valuePath.node) return; - - const copy = - jsxAttributeAction === "delete" ? undefined : t.cloneNode(valuePath.node, true); - - const parent = path.parent as t.JSXOpeningElement; - const classNameAttribute = parent.attributes.find( - (attr): attr is t.JSXAttribute => - t.isJSXAttribute(attr) && attr.name.name === "className" - ); - - matchPath(valuePath, go => ({ - StringLiteral(path) { - const { value } = path.node; - if (value) { - const trimmed = value.replace(/\s+/g, " ").trim(); - const className = getClass(trimmed); - tailwindMap.set(className, trimmed); - path.replaceWith(t.stringLiteral(className)); - } - }, - JSXExpressionContainer(path) { - go(path.get("expression")); - }, - ConditionalExpression(path) { - go(path.get("consequent")); - go(path.get("alternate")); - }, - LogicalExpression(path) { - go(path.get("right")); - }, - CallExpression(path) { - for (const arg of path.get("arguments")) { - go(arg); - } - }, - })); - - if (classNameAttribute) { - const attrValue = classNameAttribute.value!; - - // If both are string literals, we can merge them directly here - if (t.isStringLiteral(attrValue) && t.isStringLiteral(valuePath.node)) { - attrValue.value += - (attrValue.value.at(-1) === " " ? "" : " ") + valuePath.node.value; - } else { - classNameAttribute.value = t.jsxExpressionContainer( - t.callExpression(getCx(), [ - extractJSXContainer(attrValue), - extractJSXContainer(valuePath.node), - ]) - ); - } - } else { - parent.attributes.push( - t.jsxAttribute(t.jsxIdentifier("className"), valuePath.node) - ); - } - - if (jsxAttributeAction === "delete") { - path.remove(); - } else { - path.node.value = copy!; - if (Array.isArray(jsxAttributeAction) && jsxAttributeAction[0] === "rename") { - path.node.name.name = jsxAttributeAction[1]; - } - } - }, - })); -} - -type Compile = ReturnType; - -/** @internal */ -export function createPostCSS({ - tailwindConfig, - postCSSPlugins = [], - directives, -}: Pick) { - const post = postcss([ - tailwind({ - ...tailwindConfig, - content: [{ raw: "
", extension: "html" }], - }), - ...postCSSPlugins, - ]); - - const appliedDirectives = directives === "all" ? tailwindDirectives : directives; - const directiveTexts = appliedDirectives?.map(d => `@tailwind ${d};\n`).join("") ?? ""; - - return (css: string) => post.process(directiveTexts + css, { from: undefined }); -} - -const esbuildPlugin = ( - styleMap: Map, - compile: Compile -): esbuild.Plugin => ({ - name: PLUGIN_NAME, - - setup(build) { - build.onResolve({ filter: /^tailwind:.+\.css$/ }, ({ path, importer }) => { - const resolved = join(dirname(importer), path.replace(/^tailwind:/, "")); - if (styleMap.has(resolved)) { - return { - path: resolved, - namespace: ESBUILD_NAMESPACE, - }; - } - }); - - build.onResolve({ filter: RegExp(`^${name}/base$`) }, () => ({ - path: "directive:base", - namespace: ESBUILD_NAMESPACE, - })); - - build.onLoad({ filter: /.*/, namespace: ESBUILD_NAMESPACE }, async ({ path }) => { - if (path === "directive:base") { - return { - contents: (await compile(`@tailwind base;`)).css, - loader: "css", - }; - } - - if (!styleMap.has(path)) return; - const result = await compile(styleMap.get(path)!); - - return { - contents: result.css, - loader: "css", - }; - }); - }, -}); - -const vitePlugin = (styleMap: Map, compile: Compile): vite.Plugin => ({ - name: "tailwind", - resolveId(id, importer) { - if (id === `${name}/base`) { - return { - id: ROLLUP_PREFIX + "directive:base", - moduleSideEffects: true, - }; - } - - if (id.startsWith("tailwind:")) { - const resolved = join(dirname(importer!), id.slice("tailwind:".length)); - if (styleMap.has(resolved)) { - return { - id: ROLLUP_PREFIX + resolved, - moduleSideEffects: true, - }; - } - } - }, - async load(id: string) { - if (id.startsWith(ROLLUP_PREFIX)) { - const resolved = id.slice(ROLLUP_PREFIX.length); - if (resolved === "directive:base") { - return (await compile("@tailwind base;")).css; - } - - if (styleMap.has(resolved)) { - const result = await compile(styleMap.get(resolved)!); - return result.css; - } - } - }, -}); - /** * Main entry. Returns the plugins and utilities for processing Tailwind * classNames in JS. @@ -386,7 +89,7 @@ const vitePlugin = (styleMap: Map, compile: Compile): vite.Plugi * }); */ export function getTailwindPlugins(options: TailwindPluginOptions) { - const styleMap = new Map(); + const styleMap: StyleMap = new Map(); const compile = createPostCSS(options); return { diff --git a/src/shared.ts b/src/shared.ts new file mode 100644 index 0000000..d964edd --- /dev/null +++ b/src/shared.ts @@ -0,0 +1,56 @@ +import tailwind from "tailwindcss"; +import postcss from "postcss"; +import type { TailwindPluginOptions } from "./index"; + +export const { name: pkgName } = [require][0]( + process.env.BABEL_TAILWIND_BUILD ? "./package.json" : "../package.json" +); + +interface LineColumn { + line: number; + column: number; +} + +export interface SourceLocation { + filename: string; + start: LineColumn; + end: LineColumn; + text: string; +} + +export interface StyleMapEntry { + key: string; + className: string; + location: SourceLocation; +} + +export const tailwindDirectives = ["components", "utilities", "variants"] as const; + +export type StyleMap = Map; + +export function createPostCSS({ + tailwindConfig, + postCSSPlugins = [], + directives, +}: Pick) { + const post = postcss([ + tailwind({ + ...tailwindConfig, + content: [{ raw: "
", extension: "html" }], + }), + ...postCSSPlugins, + ]); + + const appliedDirectives = directives === "all" ? tailwindDirectives : directives; + const directiveTexts = appliedDirectives?.map(d => `@tailwind ${d};\n`).join("") ?? ""; + + return (css: string) => post.process(directiveTexts + css, { from: undefined }); +} + +export type Compile = ReturnType; + +export function toCSSText(tailwindMap: StyleMapEntry[]) { + return tailwindMap + .map(({ className, key }) => `.${key} {\n @apply ${className}\n}`) + .join("\n"); +} diff --git a/src/vite-plugin.ts b/src/vite-plugin.ts new file mode 100644 index 0000000..6371af9 --- /dev/null +++ b/src/vite-plugin.ts @@ -0,0 +1,40 @@ +import { dirname, join } from "node:path"; +import type * as vite from "vite"; +import { type Compile, type StyleMap, pkgName, toCSSText } from "./shared"; + +const ROLLUP_PREFIX = "\0tailwind:"; + +export const vitePlugin = (styleMap: StyleMap, compile: Compile): vite.Plugin => ({ + name: "tailwind", + resolveId(id, importer) { + if (id === `${pkgName}/base`) { + return { + id: ROLLUP_PREFIX + "directive:base", + moduleSideEffects: true, + }; + } + + if (id.startsWith("tailwind:")) { + const resolved = join(dirname(importer!), id.slice("tailwind:".length)); + if (styleMap.has(resolved)) { + return { + id: ROLLUP_PREFIX + resolved, + moduleSideEffects: true, + }; + } + } + }, + async load(id: string) { + if (id.startsWith(ROLLUP_PREFIX)) { + const resolved = id.slice(ROLLUP_PREFIX.length); + if (resolved === "directive:base") { + return (await compile("@tailwind base;")).css; + } + + if (styleMap.has(resolved)) { + const result = await compile(toCSSText(styleMap.get(resolved)!)); + return result.css; + } + } + }, +});