Skip to content

Commit

Permalink
feat: @assistant-ui-tsup/tailwindcss-transformer (#292)
Browse files Browse the repository at this point in the history
  • Loading branch information
Yonom authored Jun 22, 2024
1 parent d2b7ed4 commit ca1a401
Show file tree
Hide file tree
Showing 7 changed files with 652 additions and 70 deletions.
3 changes: 3 additions & 0 deletions packages/tailwindcss-transformer/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}
56 changes: 56 additions & 0 deletions packages/tailwindcss-transformer/README
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# `@assistant-ui-tsup/tailwindcss-transformer`

Input:

```jsx
export function Example({ flag }) {
let className = cn('absolute inset-0', flag && 'uppercase');
return <div className={cn('flex items-center text-sm', className)} />;
}
```

Ouput:

```jsx
export function Example({ flag }) {
let className = cn('cl-7601190e', flag && 'cl-d2cf63c7');
return <div className={cn('cl-f64ae6a6', className)} />;
}
```

```css
.cl-7601190e {
@apply absolute inset-0;
}

.cl-d2cf63c7 {
@apply uppercase;
}

.cl-f64ae6a6 {
@apply flex items-center text-sm;
}
```

```css
.cl-7601190e {
position: absolute;
inset: 0;
}

.cl-d2cf63c7 {
text-transform: uppercase;
}

.cl-f64ae6a6 {
display: flex;
align-items: center;
font-size: 0.875rem;
}
```

### Credits and License

Forked from [@clerk/tailwindcss-transformer](https://github.com/clerk/javascript/tree/main/packages/tailwindcss-transformer).
[Original MIT license](https://github.com/clerk/javascript/blob/main/packages/tailwindcss-transformer/LICENSE)

24 changes: 24 additions & 0 deletions packages/tailwindcss-transformer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@assistant-ui-tsup/tailwindcss-transformer",
"version": "0.1.0",
"private": true,
"license": "MIT",
"main": "./dist/index.js",
"scripts": {
"build": "tsup src/index.ts --format cjs --dts --sourcemap --clean"
},
"dependencies": {
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"postcss-value-parser": "^4.2.0",
"recast": "^0.23.9",
"tailwindcss": "^3.4.4"
},
"devDependencies": {
"@assistant-ui/tsconfig": "workspace:*",
"@types/node": "^20.14.8",
"eslint-config-next": "14.2.4",
"tsup": "^8.1.0",
"typescript": "^5.5.2"
}
}
166 changes: 166 additions & 0 deletions packages/tailwindcss-transformer/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { createHash } from "node:crypto";

import postcss from "postcss";
import * as recast from "recast";
import * as tsParser from "recast/parsers/babel-ts.js";
import tailwindcss from "tailwindcss";
import autoprefixer from "autoprefixer";

import { replaceVariableScope } from "./replace-variable-scope";

/**
* A map of hashed classnames from Tailwind CSS classes and their original values
*/
type StyleCache = Map<string, string>;

const clRegex = /^aui-[a-z0-9]{8}$/;

function isBinaryExpression(node: recast.types.namedTypes.BinaryExpression) {
return recast.types.namedTypes.BinaryExpression.check(node);
}

function isLogicalExpression(node: recast.types.namedTypes.LogicalExpression) {
return recast.types.namedTypes.LogicalExpression.check(node);
}

function isRightmostOperand(path: any) {
let parentPath = path.parentPath;
while (isLogicalExpression(parentPath.node)) {
if (parentPath.node.right !== path.node) {
return false;
}
parentPath = parentPath.parentPath;
}
return true;
}

function generateHashedClassName(value: string) {
return (
"aui-" +
createHash("sha256").update(value, "utf8").digest("hex").slice(0, 8)
);
}

function visitNode(
node: recast.types.ASTNode,
ctx: { styleCache: StyleCache },
visitors?: recast.types.Visitor,
) {
recast.visit(node, {
visitStringLiteral(path) {
if (clRegex.test(path.node.value)) {
return false;
}
if (isBinaryExpression(path.parentPath.node)) {
return false;
}
if (
isLogicalExpression(path.parentPath.node) &&
!isRightmostOperand(path)
) {
return false;
}
if (
path.parentPath.node.type === "ObjectProperty" &&
path.parentPath.node.key === path.node
) {
return false;
}
const cn = generateHashedClassName(path.node.value);
if (cn === "aui-e3b0c442") {
console.log(cn, path.parentPath.value);
}
ctx.styleCache.set(cn, path.node.value);
path.node.value = cn;
return false;
},
...visitors,
});
}

export function transform(code: string, ctx: { styleCache: StyleCache }) {
const ast = recast.parse(code, { parser: tsParser });

recast.visit(ast, {
// visit className attributes containing TW classes
visitJSXAttribute(path) {
const node = path.node;
if (node.name.name === "className") {
visitNode(node, ctx, {
// Stop traversal if we encounter a function call
// cn/cx/clsx/cva are handled by the `visitCallExpression` visitor
visitCallExpression() {
return false;
},
});
}
this.traverse(path);
},
// visit a `className` property within any object containing TW classes
visitObjectProperty(path) {
const node = path.node;
if (
path.node.key.type === "Identifier" &&
path.node.key.name === "className"
) {
visitNode(node, ctx);
}
this.traverse(path);
},
// visit function calls containing TW classes
visitCallExpression(path) {
const node = path.node;
// `className` concatenation functions
if (
node.callee.type === "Identifier" &&
["cn", "cx", "clsx"].includes(node.callee.name)
) {
visitNode(node, ctx);
}
// cva functions (note: only compatible with [email protected])
if (
node.callee.type === "Identifier" &&
node.callee.name === "cva" &&
node.arguments[0]?.type === "ObjectExpression"
) {
for (const property of node.arguments[0].properties) {
if (
property.type === "ObjectProperty" &&
property.key.type === "Identifier" &&
["base", "variants"].includes(property.key.name)
) {
visitNode(property, ctx);
}
}
}
this.traverse(path);
},
});

return recast.print(ast).code;
}

export async function generateStylesheet(
styleCache: StyleCache,
ctx: { globalCss: string },
) {
let stylesheet = "@tailwind base;\n\n";

if (ctx.globalCss) {
stylesheet += ctx.globalCss + "\n";
}

for (const [cn, value] of styleCache) {
stylesheet += `.${cn} { @apply ${value} }\n`;
}

const result = await postcss([
tailwindcss(),
replaceVariableScope,
autoprefixer(),
]).process(stylesheet, {
from: "styles.css",
});

return result.css.replace(/\n\n/g, "\n");
}
22 changes: 22 additions & 0 deletions packages/tailwindcss-transformer/src/replace-variable-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { type Declaration, type Plugin } from "postcss";
import valueParser, { type ParsedValue } from "postcss-value-parser";

export const replaceVariableScope: Plugin = {
postcssPlugin: "Replace variable scope",
Declaration(decl: Declaration) {
if (decl.prop.startsWith("--tw-")) {
decl.prop = decl.prop.replace("--tw-", "--aui-");
}
const value: ParsedValue = valueParser(decl.value);
value.walk((node) => {
if (node.type === "function" && node.value === "var") {
node.nodes.forEach((n) => {
if (n.type === "word" && n.value.startsWith("--tw-")) {
n.value = n.value.replace("--tw-", "--aui-");
}
});
}
});
decl.value = value.toString();
},
};
11 changes: 11 additions & 0 deletions packages/tailwindcss-transformer/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "@assistant-ui/tsconfig/base.json",
"compilerOptions": {
"paths": {
"@assistant-ui/*": ["../../packages/*/src"],
"@assistant-ui/react/*": ["../../packages/react/src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
Loading

0 comments on commit ca1a401

Please sign in to comment.