Optimization

This commit is contained in:
Alex 2025-02-05 21:59:03 -05:00
parent 1f5e7fa049
commit 08cd7940e2
10 changed files with 755 additions and 338 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@aet/tailwind", "name": "@aet/tailwind",
"version": "1.0.23", "version": "1.0.24",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
@ -33,12 +33,13 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@aet/eslint-rules": "2.0.35", "@aet/eslint-rules": "2.0.36",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"@types/bun": "^1.2.0", "@types/babel__traverse": "^7.20.6",
"@types/bun": "^1.2.2",
"@types/dedent": "^0.7.2", "@types/dedent": "^0.7.2",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^22.13.0", "@types/node": "^22.13.1",
"@types/postcss-safe-parser": "^5.0.4", "@types/postcss-safe-parser": "^5.0.4",
"@types/react": "^19.0.8", "@types/react": "^19.0.8",
"@types/stylis": "^4.2.7", "@types/stylis": "^4.2.7",
@ -56,8 +57,8 @@
"tslib": "^2.8.1", "tslib": "^2.8.1",
"tsup": "^8.3.6", "tsup": "^8.3.6",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.0.11", "vite": "^6.1.0",
"vitest": "^3.0.4" "vitest": "^3.0.5"
}, },
"peerDependencies": { "peerDependencies": {
"tailwindcss": "^3.4.17" "tailwindcss": "^3.4.17"

838
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -4,32 +4,59 @@ import fs from "node:fs";
const supportedTags = [ const supportedTags = [
"a", "a",
"abbr", "abbr",
"address",
"article", "article",
"b",
"bdi",
"blockquote", "blockquote",
"button", "button",
"caption",
"cite", "cite",
"code", "code",
"col",
"colgroup",
"dd",
"del",
"details", "details",
"dialog",
"div", "div",
"dl",
"dt",
"em",
"fieldset",
"figcaption",
"figure", "figure",
"footer", "footer",
"form",
"h1", "h1",
"h2", "h2",
"h3", "h3",
"h4", "h4",
"h5",
"h6",
"header",
"hr", "hr",
"i",
"iframe", "iframe",
"img", "img",
"input", "input",
"ins", "ins",
"kbd",
"label", "label",
"legend",
"li", "li",
"main", "main",
"mark",
"menu",
"meter",
"nav", "nav",
"ol", "ol",
"output", "output",
"p", "p",
"picture",
"pre", "pre",
"progress",
"q",
"rt", "rt",
"ruby", "ruby",
"section", "section",
@ -39,14 +66,21 @@ const supportedTags = [
"sub", "sub",
"summary", "summary",
"sup", "sup",
"svg",
"table", "table",
"tbody", "tbody",
"td", "td",
"textarea",
"tfoot",
"th",
"thead", "thead",
"time",
"tr", "tr",
"u",
"ul", "ul",
"var", "var",
"video", "video",
"wbr",
].sort(); ].sort();
function replaceFile(file: string, search: string | RegExp, replace: string) { function replaceFile(file: string, search: string | RegExp, replace: string) {

View File

@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`emit > supports emitting as CSS module 1`] = ` exports[`emit > supports emitting as CSS module 1`] = `
"// babel-tailwind:/Users/aet/Documents/Git/babel-tailwind/src/.temp-attr/index.module.css "// babel-tailwind:/Users/aet/Documents/Git/babel-tailwind/src/.temp-emit/index.module.css
var index_default = { var index_default = {
"tw-gqn2k6": "index_tw-gqn2k6", "tw-gqn2k6": "index_tw-gqn2k6",
"tw-1qtvvjy": "index_tw-1qtvvjy" "tw-1qtvvjy": "index_tw-1qtvvjy"

View File

@ -3,7 +3,7 @@ import { describe, it } from "vitest";
import { getBuild, matchSnapshot } from "./utils"; import { getBuild, matchSnapshot } from "./utils";
describe("emit", () => { describe("emit", () => {
const compileESBuild = getBuild("attr"); const compileESBuild = getBuild("emit");
it("supports emitting as CSS module", async () => { it("supports emitting as CSS module", async () => {
const { files } = await compileESBuild({ const { files } = await compileESBuild({

View File

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getBuild } from "./utils"; import { getBuild } from "./utils";
describe("babel-tailwind", () => { describe("babel-tailwind", () => {

View File

@ -45,8 +45,50 @@ describe("merges with existing className attribute", () => {
const clsName = getClassName("text-center"); const clsName = getClassName("text-center");
expect(files.js.text).toContain( expect(files.js.text).toContain(
`className: ({\n isEntering\n }) => _cx(isEntering ? "enter" : "exit", "${clsName}")` `className: ({\n isEntering\n }) => _cx("${clsName}", isEntering ? "enter" : "exit")`
); );
expect(files.css.text).toMatch(`.${clsName} {\n text-align: center;\n}`); expect(files.css.text).toMatch(`.${clsName} {\n text-align: center;\n}`);
}); });
it("reuses clsx in scope", async () => {
const { files } = await compileESBuild({
clsx: "clsx",
expectFiles: 2,
javascript: /* tsx */ `
import { clsx } from "clsx";
export function Hello(className) {
return (
<div className={clsx("font-semibold", className)} css="text-center" />
);
}
`,
});
expect([...files.js.text.match(/from "clsx"/g)!]).toHaveLength(1);
expect(files.js.text).toContain(
'{ className: clsx("tw-gqn2k6", "font-semibold", className)'
);
});
it("does not reuse invalid clsx", async () => {
const { files } = await compileESBuild({
clsx: "clsx",
expectFiles: 2,
javascript: /* tsx */ `
import { clsx } from "clsx";
export function Hello(className) {
let clsx = () => {};
return (
<div className={clsx("font-semibold", className)} css="text-center" />
);
}
`,
});
expect(files.js.text).toContain(
'className: _cx("tw-gqn2k6", clsx("font-semibold", className))'
);
});
}); });

View File

@ -2,6 +2,7 @@ import { basename, dirname, extname, join } from "node:path";
import type b from "@babel/core"; import type b from "@babel/core";
import { type NodePath, type types as t } from "@babel/core"; import { type NodePath, type types as t } from "@babel/core";
import type { Scope } from "@babel/traverse";
import hash from "@emotion/hash"; import hash from "@emotion/hash";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
@ -17,6 +18,14 @@ type Type = "css" | "js";
export type BabelPluginUtils = ReturnType<typeof getUtils>; export type BabelPluginUtils = ReturnType<typeof getUtils>;
interface Import {
source: string;
specifiers: {
local: string;
imported: string;
}[];
}
function getUtils({ function getUtils({
path, path,
state, state,
@ -47,6 +56,36 @@ function getUtils({
const cssMap = new Map<string, StyleMapEntry>(); const cssMap = new Map<string, StyleMapEntry>();
const jsMap = new Map<string, StyleMapEntry>(); const jsMap = new Map<string, StyleMapEntry>();
const imports: Import[] = path.node.body
.filter(node => t.isImportDeclaration(node))
.map(i => ({
source: i.source.value,
specifiers: i.specifiers
.filter(x => t.isImportSpecifier(x))
.filter(x => x.importKind === "value")
.map(x => ({
local: x.local.name,
imported: t.isStringLiteral(x.imported) ? x.imported.value : x.imported.name,
})),
}));
let existingCx: string | undefined;
switch (clsx) {
case "emotion":
existingCx = imports
.find(i => i.source === "@emotion/css")
?.specifiers.find(s => s.imported === "cx")?.local;
break;
case "clsx":
existingCx = imports
.find(i => i.source === "clsx")
?.specifiers.find(s => s.imported === "clsx")?.local;
break;
case "classnames":
existingCx = imports.find(i => i.source === "classnames")?.specifiers[0]?.local;
break;
}
function getStyleImport() { function getStyleImport() {
styleImport ??= path.scope.generateUidIdentifier("styles"); styleImport ??= path.scope.generateUidIdentifier("styles");
return t.cloneNode(styleImport); return t.cloneNode(styleImport);
@ -59,7 +98,16 @@ function getUtils({
return t.cloneNode(cssModuleImport); return t.cloneNode(cssModuleImport);
}; };
const reuseImport = (scope: Scope, id?: string) => {
if (id && scope.getBinding(id) === path.scope.getBinding(id)) {
return t.identifier(id);
}
};
return { return {
program: path,
existingCx,
getClass(type: Type, value: string) { getClass(type: Type, value: string) {
return type === "css" ? getClass(value) : "tw_" + hash(value); return type === "css" ? getClass(value) : "tw_" + hash(value);
}, },
@ -74,7 +122,7 @@ function getUtils({
.join("\n"), .join("\n"),
}), }),
recordIfAbsent(type: "css", entry: StyleMapEntry) { recordIfAbsent(type: Type, entry: StyleMapEntry) {
const map = type === "css" ? cssMap : jsMap; const map = type === "css" ? cssMap : jsMap;
if (!map.has(entry.key)) { if (!map.has(entry.key)) {
map.set(entry.key, entry); map.set(entry.key, entry);
@ -100,8 +148,11 @@ function getUtils({
} }
}, },
getCx: () => { getCx: (localScope: Scope) => {
if (cx == null) { if (cx == null) {
const reuse = reuseImport(localScope, existingCx);
if (reuse) return reuse;
cx = path.scope.generateUidIdentifier("cx"); cx = path.scope.generateUidIdentifier("cx");
path.node.body.unshift(getClsxImport(t, cx, clsx)); path.node.body.unshift(getClsxImport(t, cx, clsx));
} }
@ -295,8 +346,8 @@ export function babelTailwind(
if (classNameAttribute) { if (classNameAttribute) {
const attrValue = classNameAttribute.value!; const attrValue = classNameAttribute.value!;
const wrap = (originalValue: b.types.Expression) => const wrap = (...originalValue: (b.types.Expression | b.types.SpreadElement)[]) =>
t.callExpression(_.getCx(), [originalValue, valuePathNode]); t.callExpression(_.getCx(path.scope), [valuePathNode, ...originalValue]);
// If both are string literals, we can merge them directly here // If both are string literals, we can merge them directly here
if (t.isStringLiteral(attrValue) && t.isStringLiteral(valuePathNode)) { if (t.isStringLiteral(attrValue) && t.isStringLiteral(valuePathNode)) {
@ -309,13 +360,32 @@ export function babelTailwind(
!t.isBlockStatement(internalAttrValue.body) !t.isBlockStatement(internalAttrValue.body)
) { ) {
internalAttrValue.body = wrap(internalAttrValue.body); internalAttrValue.body = wrap(internalAttrValue.body);
} else if (
// if the existing className is already wrapped with cx, we unwrap it
// to avoid double calling: cx(cx())
t.isCallExpression(internalAttrValue) &&
t.isIdentifier(internalAttrValue.callee) &&
_.existingCx &&
_.program.scope
.getBinding(_.existingCx)
?.referencePaths.map(p => p.node)
.includes(internalAttrValue.callee)
) {
classNameAttribute.value = t.jsxExpressionContainer(
wrap(
...(internalAttrValue.arguments as (
| b.types.Expression
| b.types.SpreadElement
)[])
)
);
} else { } else {
classNameAttribute.value = t.jsxExpressionContainer(wrap(internalAttrValue)); classNameAttribute.value = t.jsxExpressionContainer(wrap(internalAttrValue));
} }
} }
} else { } else {
const wrap = (originalValue: b.types.Expression) => const wrap = (originalValue: b.types.Expression) =>
t.callExpression(_.getCx(), [valuePathNode, originalValue]); t.callExpression(_.getCx(path.scope), [valuePathNode, originalValue]);
const rest = parent.attributes.filter(attr => t.isJSXSpreadAttribute(attr)); const rest = parent.attributes.filter(attr => t.isJSXSpreadAttribute(attr));
let arg; let arg;
@ -368,7 +438,7 @@ export function babelTailwind(
} else { } else {
const containerValue = t.isStringLiteral(valuePathNode) const containerValue = t.isStringLiteral(valuePathNode)
? valuePathNode ? valuePathNode
: t.callExpression(_.getCx(), [valuePathNode]); : t.callExpression(_.getCx(path.scope), [valuePathNode]);
parent.attributes.push( parent.attributes.push(
t.jsxAttribute( t.jsxAttribute(
@ -400,7 +470,10 @@ function getClsxImport(t: BabelTypes, cx: t.Identifier, clsx: string) {
t.stringLiteral("@emotion/css") t.stringLiteral("@emotion/css")
); );
case "clsx": case "clsx":
return t.importDeclaration([t.importDefaultSpecifier(cx)], t.stringLiteral("clsx")); return t.importDeclaration(
[t.importSpecifier(cx, t.identifier("clsx"))],
t.stringLiteral("clsx")
);
case "classnames": case "classnames":
return t.importDeclaration( return t.importDeclaration(
[t.importDefaultSpecifier(cx)], [t.importDefaultSpecifier(cx)],

View File

@ -164,32 +164,59 @@ const isNodePath = <T extends t.Node>(
const supportedTags = new Set<SupportedTag>([ const supportedTags = new Set<SupportedTag>([
"a", "a",
"abbr", "abbr",
"address",
"article", "article",
"b",
"bdi",
"blockquote", "blockquote",
"button", "button",
"caption",
"cite", "cite",
"code", "code",
"col",
"colgroup",
"dd",
"del",
"details", "details",
"dialog",
"div", "div",
"dl",
"dt",
"em",
"fieldset",
"figcaption",
"figure", "figure",
"footer", "footer",
"form",
"h1", "h1",
"h2", "h2",
"h3", "h3",
"h4", "h4",
"h5",
"h6",
"header",
"hr", "hr",
"i",
"iframe", "iframe",
"img", "img",
"input", "input",
"ins", "ins",
"kbd",
"label", "label",
"legend",
"li", "li",
"main", "main",
"mark",
"menu",
"meter",
"nav", "nav",
"ol", "ol",
"output", "output",
"p", "p",
"picture",
"pre", "pre",
"progress",
"q",
"rt", "rt",
"ruby", "ruby",
"section", "section",
@ -199,12 +226,19 @@ const supportedTags = new Set<SupportedTag>([
"sub", "sub",
"summary", "summary",
"sup", "sup",
"svg",
"table", "table",
"tbody", "tbody",
"td", "td",
"textarea",
"tfoot",
"th",
"thead", "thead",
"time",
"tr", "tr",
"u",
"ul", "ul",
"var", "var",
"video", "video",
"wbr",
]); ]);

36
src/macro.d.ts vendored
View File

@ -13,32 +13,59 @@ type CSSAttributeValue = string | (string | RecursiveStringObject)[];
export type SupportedTag = export type SupportedTag =
| "a" | "a"
| "abbr" | "abbr"
| "address"
| "article" | "article"
| "b"
| "bdi"
| "blockquote" | "blockquote"
| "button" | "button"
| "caption"
| "cite" | "cite"
| "code" | "code"
| "col"
| "colgroup"
| "dd"
| "del"
| "details" | "details"
| "dialog"
| "div" | "div"
| "dl"
| "dt"
| "em"
| "fieldset"
| "figcaption"
| "figure" | "figure"
| "footer" | "footer"
| "form"
| "h1" | "h1"
| "h2" | "h2"
| "h3" | "h3"
| "h4" | "h4"
| "h5"
| "h6"
| "header"
| "hr" | "hr"
| "i"
| "iframe" | "iframe"
| "img" | "img"
| "input" | "input"
| "ins" | "ins"
| "kbd"
| "label" | "label"
| "legend"
| "li" | "li"
| "main" | "main"
| "mark"
| "menu"
| "meter"
| "nav" | "nav"
| "ol" | "ol"
| "output" | "output"
| "p" | "p"
| "picture"
| "pre" | "pre"
| "progress"
| "q"
| "rt" | "rt"
| "ruby" | "ruby"
| "section" | "section"
@ -48,14 +75,21 @@ export type SupportedTag =
| "sub" | "sub"
| "summary" | "summary"
| "sup" | "sup"
| "svg"
| "table" | "table"
| "tbody" | "tbody"
| "td" | "td"
| "textarea"
| "tfoot"
| "th"
| "thead" | "thead"
| "time"
| "tr" | "tr"
| "u"
| "ul" | "ul"
| "var" | "var"
| "video"; | "video"
| "wbr";
type Modifier = type Modifier =
| "2xl" | "2xl"