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",
"version": "1.0.23",
"version": "1.0.24",
"license": "MIT",
"type": "module",
"scripts": {
@ -33,12 +33,13 @@
}
},
"devDependencies": {
"@aet/eslint-rules": "2.0.35",
"@aet/eslint-rules": "2.0.36",
"@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/lodash-es": "^4.17.12",
"@types/node": "^22.13.0",
"@types/node": "^22.13.1",
"@types/postcss-safe-parser": "^5.0.4",
"@types/react": "^19.0.8",
"@types/stylis": "^4.2.7",
@ -56,8 +57,8 @@
"tslib": "^2.8.1",
"tsup": "^8.3.6",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vitest": "^3.0.4"
"vite": "^6.1.0",
"vitest": "^3.0.5"
},
"peerDependencies": {
"tailwindcss": "^3.4.17"
@ -87,4 +88,4 @@
"is-core-module": "npm:@nolyfill/is-core-module@^1"
}
}
}
}

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

View File

@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
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 = {
"tw-gqn2k6": "index_tw-gqn2k6",
"tw-1qtvvjy": "index_tw-1qtvvjy"

View File

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

View File

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

View File

@ -45,8 +45,50 @@ describe("merges with existing className attribute", () => {
const clsName = getClassName("text-center");
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}`);
});
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 NodePath, type types as t } from "@babel/core";
import type { Scope } from "@babel/traverse";
import hash from "@emotion/hash";
import invariant from "tiny-invariant";
@ -17,6 +18,14 @@ type Type = "css" | "js";
export type BabelPluginUtils = ReturnType<typeof getUtils>;
interface Import {
source: string;
specifiers: {
local: string;
imported: string;
}[];
}
function getUtils({
path,
state,
@ -47,6 +56,36 @@ function getUtils({
const cssMap = 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() {
styleImport ??= path.scope.generateUidIdentifier("styles");
return t.cloneNode(styleImport);
@ -59,7 +98,16 @@ function getUtils({
return t.cloneNode(cssModuleImport);
};
const reuseImport = (scope: Scope, id?: string) => {
if (id && scope.getBinding(id) === path.scope.getBinding(id)) {
return t.identifier(id);
}
};
return {
program: path,
existingCx,
getClass(type: Type, value: string) {
return type === "css" ? getClass(value) : "tw_" + hash(value);
},
@ -74,7 +122,7 @@ function getUtils({
.join("\n"),
}),
recordIfAbsent(type: "css", entry: StyleMapEntry) {
recordIfAbsent(type: Type, entry: StyleMapEntry) {
const map = type === "css" ? cssMap : jsMap;
if (!map.has(entry.key)) {
map.set(entry.key, entry);
@ -100,8 +148,11 @@ function getUtils({
}
},
getCx: () => {
getCx: (localScope: Scope) => {
if (cx == null) {
const reuse = reuseImport(localScope, existingCx);
if (reuse) return reuse;
cx = path.scope.generateUidIdentifier("cx");
path.node.body.unshift(getClsxImport(t, cx, clsx));
}
@ -295,8 +346,8 @@ export function babelTailwind(
if (classNameAttribute) {
const attrValue = classNameAttribute.value!;
const wrap = (originalValue: b.types.Expression) =>
t.callExpression(_.getCx(), [originalValue, valuePathNode]);
const wrap = (...originalValue: (b.types.Expression | b.types.SpreadElement)[]) =>
t.callExpression(_.getCx(path.scope), [valuePathNode, ...originalValue]);
// If both are string literals, we can merge them directly here
if (t.isStringLiteral(attrValue) && t.isStringLiteral(valuePathNode)) {
@ -309,13 +360,32 @@ export function babelTailwind(
!t.isBlockStatement(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 {
classNameAttribute.value = t.jsxExpressionContainer(wrap(internalAttrValue));
}
}
} else {
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));
let arg;
@ -368,7 +438,7 @@ export function babelTailwind(
} else {
const containerValue = t.isStringLiteral(valuePathNode)
? valuePathNode
: t.callExpression(_.getCx(), [valuePathNode]);
: t.callExpression(_.getCx(path.scope), [valuePathNode]);
parent.attributes.push(
t.jsxAttribute(
@ -400,7 +470,10 @@ function getClsxImport(t: BabelTypes, cx: t.Identifier, clsx: string) {
t.stringLiteral("@emotion/css")
);
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":
return t.importDeclaration(
[t.importDefaultSpecifier(cx)],

View File

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

36
src/macro.d.ts vendored
View File

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