Compare commits

...

6 Commits

Author SHA1 Message Date
1d0a8a1c36 Update 2025-06-13 01:08:36 -04:00
bd683df539 Extract class name composition logic 2025-03-04 04:01:55 -05:00
52b19b3b36 Fix wrapper 2025-02-10 02:29:07 -05:00
8e41208a14 composeRenderProps 2025-02-09 21:37:27 -05:00
17f9527ab4 Fix 2025-02-05 22:02:20 -05:00
08cd7940e2 Optimization 2025-02-05 21:59:03 -05:00
27 changed files with 2141 additions and 1485 deletions

View File

@ -3,6 +3,8 @@
Compile-run Tailwind compiler.
```tsx
/// <reference types="@aet/tailwind/react-env" />
export function App() {
return <div css="flex m-0"></div>;
}

View File

@ -1,6 +1,6 @@
{
"name": "@aet/tailwind",
"version": "1.0.23",
"version": "1.0.35",
"license": "MIT",
"type": "module",
"scripts": {
@ -14,7 +14,7 @@
"exports": {
".": "./dist/index.js",
"./package.json": "./package.json",
"./classed": "./dist/classed.js",
"./utils": "./dist/utils.js",
"./css-to-js": "./dist/css-to-js.js",
"./base": {
"types": "./dist/base.d.ts"
@ -30,49 +30,55 @@
"./plugin/typography": "./dist/vendor/typography.js",
"./macro": {
"types": "./dist/macro.d.ts"
},
"./react-env": {
"types": "./dist/react-env.d.ts"
}
},
"devDependencies": {
"@aet/eslint-rules": "2.0.35",
"@aet/eslint-rules": "^2.0.52",
"@types/babel__core": "^7.20.5",
"@types/bun": "^1.2.0",
"@types/bun": "^1.2.16",
"@types/dedent": "^0.7.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.13.0",
"@types/node": "^24.0.1",
"@types/postcss-safe-parser": "^5.0.4",
"@types/react": "^19.0.8",
"@types/react": "^19.1.8",
"@types/stylis": "^4.2.7",
"@vitejs/plugin-react": "^4.5.2",
"cli-highlight": "^2.1.11",
"clsx": "^2.1.1",
"colord": "^2.9.3",
"css-what": "^6.1.0",
"dedent": "^1.5.3",
"dedent": "^1.6.0",
"esbuild-register": "^3.6.0",
"eslint": "^9.19.0",
"nolyfill": "^1.0.43",
"eslint": "^9.28.0",
"nolyfill": "^1.0.44",
"postcss-nested": "^7.0.2",
"prettier": "^3.4.2",
"prettier": "^3.5.3",
"react-refresh": "^0.17.0",
"tailwindcss": "^3.4.17",
"tslib": "^2.8.1",
"tsup": "^8.3.6",
"typescript": "^5.7.3",
"vite": "^6.0.11",
"vitest": "^3.0.4"
"tsup": "^8.5.0",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.2.3"
},
"peerDependencies": {
"tailwindcss": "^3.4.17"
},
"dependencies": {
"@babel/core": "^7.26.7",
"@babel/core": "^7.27.4",
"@emotion/hash": "^0.9.2",
"esbuild": "^0.24.2",
"clsx": "^2.1.1",
"esbuild": "^0.25.5",
"json5": "^2.2.3",
"lodash-es": "^4.17.21",
"postcss": "^8.5.1",
"postcss-selector-parser": "^7.0.0",
"stylis": "^4.3.5",
"postcss": "^8.5.5",
"postcss-selector-parser": "^7.1.0",
"stylis": "^4.3.6",
"tiny-invariant": "^1.3.3",
"type-fest": "^4.33.0"
"type-fest": "^4.41.0"
},
"prettier": {
"arrowParens": "avoid",
@ -84,7 +90,11 @@
},
"pnpm": {
"overrides": {
"is-core-module": "npm:@nolyfill/is-core-module@^1"
}
"is-core-module": "npm:@nolyfill/is-core-module@^1",
"@babel/types": "7.26.7"
},
"onlyBuiltDependencies": [
"esbuild"
]
}
}
}

2698
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
#!/usr/bin/env bun
#!/usr/bin/env tsx
import { promises as fs } from "node:fs";
import { pick } from "lodash-es";
import { build, defineConfig } from "tsup";
import pkg from "../package.json" with { type: "json" };
@ -24,7 +23,7 @@ const tsupConfig = defineConfig({
await build({
...tsupConfig,
entry: ["src/classed.tsx", "src/css-to-js.ts"],
entry: ["src/utils.tsx", "src/css-to-js.ts"],
outDir: "dist",
external: ["react", "react/jsx-runtime", "clsx"],
clean: true,
@ -33,7 +32,7 @@ await build({
await Promise.all([
build({
...tsupConfig,
entry: ["src/classed.tsx", "src/css-to-js.ts"],
entry: ["src/utils.tsx", "src/css-to-js.ts"],
outDir: "dist",
external: ["react", "react/jsx-runtime", "clsx"],
}),
@ -57,29 +56,17 @@ await Promise.all([
external: ["tailwindcss/plugin", "tailwindcss/colors", "tailwindcss/defaultTheme"],
})
),
Bun.write(
"dist/package.json",
JSON.stringify(
pick(pkg, [
"name",
"version",
"type",
"license",
"dependencies",
"author",
"exports",
]),
null,
2
).replaceAll("./dist/", "./")
),
]);
await Promise.all([
fs.copyFile("README.md", "dist/README.md"),
fs.copyFile("LICENSE.md", "dist/LICENSE.md"),
fs.copyFile("src/macro.d.ts", "dist/macro.d.ts"),
Bun.write(`dist/base.d.ts`, `/**\n * \`@tailwind base\` component.\n */\nexport {};`),
fs.copyFile("src/react-env.d.ts", "dist/react-env.d.ts"),
fs.writeFile(
`dist/base.d.ts`,
`/**\n * \`@tailwind base\` component.\n */\nexport {};`
),
]);
process.exit(0);

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

@ -0,0 +1,33 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`group > supports group 1`] = `
"import { jsx, jsxs } from "react/jsx-runtime";
function Hello() {
return /* @__PURE__ */ jsx("li", { className: "tw-1d1woxu", children: /* @__PURE__ */ jsxs("a", { href: "tel:{person.phone}", className: "tw-gbesv1", children: [
/* @__PURE__ */ jsx("span", { className: "tw-1psr9tm", children: "Call" }),
/* @__PURE__ */ jsx("svg", { className: "tw-f3p2y0" })
] }) });
}
export {
Hello
};"
`;
exports[`group > supports group 2`] = `
".tw-gbesv1 {
visibility: hidden;
}
.group\\/item:hover .tw-gbesv1 {
visibility: visible;
}
.group\\/edit:hover .tw-1psr9tm {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity, 1));
}
.group\\/edit:hover .tw-f3p2y0 {
--tw-translate-x: 0.125rem;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
}"
`;

View File

@ -1,8 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`spread > supports spread attribute in "css" attribute (2) 1`] = `
"import { cx as _cx } from "@emotion/css";
import * as _tslib from "tslib";
"import * as _tslib from "tslib";
import { cx as _cx } from "@emotion/css";
import { jsx } from "react/jsx-runtime";
function Hello(props) {
props = {

View File

@ -41,7 +41,7 @@ exports[`babel-tailwind > supports grouped tw 2`] = `
`;
exports[`babel-tailwind > supports styled components usage 1`] = `
"import { classed as _classed } from "@aet/tailwind/classed";
"import { classed as _classed } from "@aet/tailwind/utils";
var Div = _classed("div", "tw-gqn2k6");"
`;

View File

@ -61,115 +61,4 @@ describe("attr", () => {
matchSnapshot(files);
});
it("fails", async () => {
const { files } = await compileESBuild({
clsx: "emotion",
expectFiles: 2,
javascript: /* tsx */ `
type Type = "dependencies" | "devDependencies" | "peerDependencies"
const Version = classed("span", tw\`text-[var(--color-fg-muted)]\`)
function TreeItem({
name,
version,
type,
}: {
name: string
version: string
type: Type
}) {
const [open, toggle] = useToggle(false)
const query = useQuery({
...getRegistryPackageInfo(name),
enabled: open,
})
const versions = query.data ? Object.keys(query.data.versions) : undefined
const matchingVersion = versions
? semverMaxSatisfying(versions, version)
: undefined
const currentVersion =
matchingVersion != null ? query.data?.versions[matchingVersion] : undefined
const data = currentVersion
? Object.entries(currentVersion[type] ?? {})
: undefined
const isDeprecated = true
const hasNoDeps = data?.length === 0
const Icon = hasNoDeps ? SmallMinus : open ? ChevronDown : ChevronRight
return (
<li data-loading={query.isLoading}>
<div css="flex items-center gap-1 ps-0">
<span css="leading-3">
<Icon
className={Classes.TREE_NODE_CARET}
css={["min-w-0 p-0", hasNoDeps && "cursor-default"]}
onClick={hasNoDeps ? undefined : toggle}
/>
</span>
<Link href={\`/package/\${name}\`}>{name}</Link>
<span
css={[
"text-[var(--color-fg-muted)]",
isDeprecated && "italic line-through opacity-80",
]}
>
{version}
</span>
</div>
{!open || hasNoDeps ? null : query.error ? (
<div css="mb-2 ml-4 mt-1 max-w-96">
<Callout compact intent={Intent.DANGER} css="rounded-md">
{(query.error as Error).message}
</Callout>
</div>
) : data ? (
<ul className={Classes.LIST} css="ml-0 list-none">
{data.map(([dep, version]) => (
<TreeItem key={dep} name={dep} version={version} type={type} />
))}
</ul>
) : (
<div className={Classes.SKELETON} css="mb-2 ml-5 mt-1 h-4 w-40" />
)}
</li>
)
}
export function DepList({
title,
deps,
type,
}: {
title: string
deps: Record<string, string>
count?: number
type: Type
}) {
const entries = Object.entries(deps)
return (
<div className="wmde-markdown-var">
<H4>
{title} ({entries.length})
</H4>
<ul className={Classes.LIST} css="list-none p-0">
{entries.map(([dep, version]) => (
<TreeItem key={dep} name={dep} version={version} type={type} />
))}
</ul>
</div>
)
}
`,
});
files;
});
});

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { createPostCSS } from "../index";
import { createPostCSS, getClassName } from "../index";
import { getBuild, minCSS, name } from "./utils";
@ -10,7 +10,6 @@ describe("babel-tailwind", () => {
it("supports importing tailwind/base", async () => {
const postcss = createPostCSS({
tailwindConfig: {},
postCSSPlugins: [],
});
const base = await postcss("@tailwind base;");
const { files } = await compileESBuild({
@ -24,4 +23,23 @@ describe("babel-tailwind", () => {
expect(files.js.text).toBe("");
expect(minCSS(files.css.text)).toContain(minCSS(base));
});
it("supports composeRenderProps", async () => {
const { files } = await compileESBuild({
clsx: "clsx",
expectFiles: 2,
javascript: /* tsx */ `
export function Hello() {
return (
<div css="text-center">
Hello, world!
</div>
);
}
`,
});
const clsName = getClassName("text-center");
expect(files.js.text).toContain(`{ className: "${clsName}",`);
});
});

View File

@ -3,9 +3,9 @@ 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 () => {
it.only("supports emitting as CSS module", async () => {
const { files } = await compileESBuild({
clsx: "emotion",
expectFiles: 2,

View File

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

View File

@ -0,0 +1,26 @@
import { describe, it } from "vitest";
import { getBuild, matchSnapshot } from "./utils";
describe("group", () => {
const compileESBuild = getBuild("group");
it("supports group", async () => {
const { files } = await compileESBuild({
clsx: "emotion",
// expectFiles: 2,
javascript: /* tsx */ `
export function Hello() {
return (
<li css="group/item">
<a css="group/edit invisible group-hover/item:visible" href="tel:{person.phone}">
<span css="group-hover/edit:text-gray-700">Call</span>
<svg css="group-hover/edit:translate-x-0.5 group-hover/edit:text-gray-500"></svg>
</a>
</li>
);
}
`,
});
matchSnapshot(files);
});
});

View File

@ -45,8 +45,94 @@ 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("supports composeRenderProps", async () => {
const { files } = await compileESBuild({
clsx: "clsx",
expectFiles: 2,
composeRenderProps: true,
javascript: /* tsx */ `
export function Hello(props) {
return (
<div {...props} css="text-center">
Hello, world!
</div>
);
}
`,
});
const clsName = getClassName("text-center");
expect(files.js.text).toContain(
`{ ...props, className: _composeClassName("${clsName}", _className),`
);
});
it("supports composeRenderProps (2)", async () => {
const { files } = await compileESBuild({
clsx: "clsx",
expectFiles: 2,
composeRenderProps: true,
javascript: /* tsx */ `
export function Hello({ className, ...props }) {
return (
<div className={className} css="text-center">
<span {...props}>Hello, world!</span>
</div>
);
}
`,
});
const clsName = getClassName("text-center");
expect(files.js.text).toContain(
`{ className: _composeClassName("${clsName}", className),`
);
});
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

@ -1,6 +1,7 @@
import { promises as fs } from "node:fs";
import { join, resolve } from "node:path";
import type { PluginItem } from "@babel/core";
import dedent from "dedent";
import * as esbuild from "esbuild";
import { afterEach, beforeEach, expect } from "vitest";
@ -35,12 +36,14 @@ export function getBuild(name: string) {
return async function compileESBuild({
javascript,
esbuild: esbuildOptions,
babelPlugins,
expectFiles,
...options
}: Omit<TailwindPluginOptions, "compile"> & {
esbuild?: esbuild.BuildOptions;
javascript: string;
expectFiles?: number;
babelPlugins?: PluginItem[];
}) {
const tailwind = getTailwindPlugins({
tailwindConfig: {},
@ -52,15 +55,19 @@ export function getBuild(name: string) {
external: [
"react",
"react/jsx-runtime",
"react-aria-components",
"@emotion/css",
"clsx",
"tslib",
"@aet/tailwind/classed",
"@aet/tailwind/utils",
],
outdir: "dist",
format: "esm",
entryPoints: [await write("index.tsx", dedent(javascript))],
plugins: [babelPlugin({ plugins: [tailwind.babel()] }), tailwind.esbuild()],
plugins: [
babelPlugin({ plugins: [tailwind.babel(), ...(babelPlugins ?? [])] }),
tailwind.esbuild(),
],
...esbuildOptions,
});

View File

@ -1,12 +1,13 @@
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 Node, type NodePath, type types as t } from "@babel/core";
import hash from "@emotion/hash";
import { memoize } from "lodash-es";
import invariant from "tiny-invariant";
import { type ResolveTailwindOptions, getClassName } from "../index";
import { type SourceLocation, type StyleMapEntry, classedName } from "../shared";
import { type SourceLocation, type StyleMapEntry, utilsName } from "../shared";
import { handleMacro } from "./macro";
import { evaluateArgs, trim } from "./utils";
@ -15,8 +16,18 @@ export type ClassNameCollector = (path: string, entries: StyleMapEntry[]) => voi
type BabelTypes = typeof b.types;
type Type = "css" | "js";
type Scope = ReturnType<typeof NodePath.prototype.getScope>;
export type BabelPluginUtils = ReturnType<typeof getUtils>;
interface Import {
source: string;
specifiers: {
local: string;
imported: string;
}[];
}
function getUtils({
path,
state,
@ -39,27 +50,79 @@ function getUtils({
} = options;
let cx: t.Identifier;
let tslibImport: t.Identifier;
let styleImport: t.Identifier;
let classedImport: t.Identifier;
let cssModuleImport: 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);
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;
}
const getCssModuleImport = () => {
if (cssModuleImport == null) {
cssModuleImport = path.scope.generateUidIdentifier("cssModule");
function reuseImport(scope: Scope) {
if (
existingCx &&
scope.getBinding(existingCx) === path.scope.getBinding(existingCx)
) {
return t.identifier(existingCx);
}
return t.cloneNode(cssModuleImport);
};
}
function cacheNode<N extends Node>(fn: () => N) {
let cache: N | undefined;
return Object.assign(
(): N => {
cache ??= fn();
return t.cloneNode(cache);
},
{
getCache() {
return cache;
},
}
);
}
const getStyleImport = cacheNode(() => path.scope.generateUidIdentifier("styles"));
const getCssModuleImport = cacheNode(() =>
path.scope.generateUidIdentifier("cssModule")
);
const getUtilsImport = memoize(() => {
const importDecl = t.importDeclaration([], t.stringLiteral(utilsName));
path.node.body.unshift(importDecl);
return importDecl;
});
return {
program: path,
existingCx,
getClass(type: Type, value: string) {
return type === "css" ? getClass(value) : "tw_" + hash(value);
},
@ -74,7 +137,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,39 +163,45 @@ function getUtils({
}
},
getCx: () => {
getCx: (localScope: Scope) => {
if (cx == null) {
const reuse = reuseImport(localScope);
if (reuse) return reuse;
cx = path.scope.generateUidIdentifier("cx");
path.node.body.unshift(getClsxImport(t, cx, clsx));
// If you unshift, react-refresh/babel will insert _s(Component) right above
// the component declaration, which is invalid.
path.node.body.push(getClsxImport(t, cx, clsx));
}
return t.cloneNode(cx);
},
getTSlibImport: () => {
if (tslibImport == null) {
tslibImport = path.scope.generateUidIdentifier("tslib");
path.node.body.unshift(
t.importDeclaration(
[t.importNamespaceSpecifier(tslibImport)],
t.stringLiteral("tslib")
)
);
}
return t.cloneNode(tslibImport);
},
getTSlibImport: cacheNode(() => {
const tslibImport = path.scope.generateUidIdentifier("tslib");
path.node.body.push(
t.importDeclaration(
[t.importNamespaceSpecifier(tslibImport)],
t.stringLiteral("tslib")
)
);
return tslibImport;
}),
getClassedImport: () => {
if (classedImport == null) {
classedImport = path.scope.generateUidIdentifier("classed");
path.node.body.unshift(
t.importDeclaration(
[t.importSpecifier(classedImport, t.identifier("classed"))],
t.stringLiteral(classedName)
)
);
}
return t.cloneNode(classedImport);
},
getClsCompose: cacheNode(() => {
const clsComposeImport = path.scope.generateUidIdentifier("composeClassName");
getUtilsImport().specifiers.push(
t.importSpecifier(clsComposeImport, t.identifier("composeClassName"))
);
return clsComposeImport;
}),
getClassedImport: cacheNode(() => {
const classedImport = path.scope.generateUidIdentifier("classed");
getUtilsImport().specifiers.push(
t.importSpecifier(classedImport, t.identifier("classed"))
);
return classedImport;
}),
getCssModuleImport,
@ -152,6 +221,7 @@ function getUtils({
if (!cssMap.size && !jsMap.size) return;
invariant(filename, "babel: missing state.filename");
const cssModuleImport = getCssModuleImport.getCache();
if (cssMap.size) {
const cssName =
basename(filename, extname(filename)) +
@ -204,6 +274,7 @@ export function babelTailwind(
getClassName: getClass = getClassName,
jsxAttributeAction = "delete",
jsxAttributeName = "css",
composeRenderProps,
} = options;
return definePlugin<BabelPluginUtils>(({ types: t }) => ({
@ -282,6 +353,13 @@ export function babelTailwind(
},
}));
const {
identifier: id,
jsxExpressionContainer: jsxBox,
jsxIdentifier: jsxId,
callExpression: call,
} = t;
let valuePathNode = extractJSXContainer(valuePath.node);
if (
t.isArrayExpression(valuePathNode) &&
@ -293,82 +371,105 @@ export function babelTailwind(
);
}
const wrap = (existing: b.types.Expression) =>
composeRenderProps
? call(_.getClsCompose(), [valuePathNode, existing])
: call(_.getCx(path.scope), [valuePathNode, existing]);
// There is an existing className attribute
if (classNameAttribute) {
const attrValue = classNameAttribute.value!;
const wrap = (originalValue: b.types.Expression) =>
t.callExpression(_.getCx(), [originalValue, valuePathNode]);
// If both are string literals, we can merge them directly here
if (t.isStringLiteral(attrValue) && t.isStringLiteral(valuePathNode)) {
attrValue.value +=
(attrValue.value.at(-1) === " " ? "" : " ") + valuePathNode.value;
} else {
const internalAttrValue = extractJSXContainer(attrValue);
const internal = extractJSXContainer(attrValue);
if (
t.isArrowFunctionExpression(internalAttrValue) &&
!t.isBlockStatement(internalAttrValue.body)
t.isArrowFunctionExpression(internal) &&
!t.isBlockStatement(internal.body)
) {
internalAttrValue.body = wrap(internalAttrValue.body);
// className={({ isEntering }) => isEntering ? "enter" : "exit"}
// className: ({ isEntering }) => _cx("${clsName}", isEntering ? "enter" : "exit")
internal.body = wrap(internal.body);
} else if (
// if the existing className is already wrapped with cx, we unwrap it
// to avoid double calling: cx(cx())
t.isCallExpression(internal) &&
t.isIdentifier(internal.callee) &&
_.existingCx &&
_.program.scope
.getBinding(_.existingCx)
?.referencePaths.map(p => p.node)
.includes(internal.callee)
) {
classNameAttribute.value = jsxBox(
call(_.getCx(path.scope), [
valuePathNode,
...(internal.arguments as (b.types.Expression | b.types.SpreadElement)[]),
])
);
} else {
classNameAttribute.value = t.jsxExpressionContainer(wrap(internalAttrValue));
classNameAttribute.value = jsxBox(wrap(internal));
}
}
} else {
const wrap = (originalValue: b.types.Expression) =>
t.callExpression(_.getCx(), [valuePathNode, originalValue]);
const rest = parent.attributes.filter(attr => t.isJSXSpreadAttribute(attr));
let arg;
// if there is only one JSX spread attribute and it's an identifier
// ... {...props} />
if (rest.length === 1 && (arg = rest[0].argument) && t.isIdentifier(arg)) {
// props from argument and not modified anywhere
// props from argument and not modified anywhere, get the declaration of this argument
const scope = path.scope.getBinding(arg.name);
let index: number;
// node is an identifier or object pattern in `params`
// (props) => ... or ({ ...props }) => ...
const node = scope?.path.node;
if (
scope &&
!scope.constantViolations.length &&
t.isFunctionDeclaration(scope.path.parent) &&
(t.isFunctionDeclaration(scope.path.parent) ||
t.isArrowFunctionExpression(scope.path.parent)) &&
(index = (scope.path.parent.params as t.Node[]).indexOf(node!)) !== -1 &&
(t.isIdentifier(node) || t.isObjectPattern(node))
) {
const clsVar = path.scope.generateUidIdentifier("className");
if (t.isIdentifier(node)) {
// (props) => ...
// ↪ ({ className, ...props }) => ...
scope.path.parent.params[index] = t.objectPattern([
t.objectProperty(t.identifier("className"), clsVar),
t.objectProperty(id("className"), clsVar),
t.restElement(node),
]);
} else {
node.properties.unshift(
t.objectProperty(t.identifier("className"), clsVar)
);
// ({ ...props }) => ...
// ↪ ({ className, ...props }) => ...
node.properties.unshift(t.objectProperty(id("className"), clsVar));
}
parent.attributes.push(
t.jsxAttribute(
t.jsxIdentifier("className"),
t.jsxExpressionContainer(wrap(clsVar))
)
t.jsxAttribute(jsxId("className"), jsxBox(wrap(clsVar)))
);
} else {
const tslibImport = _.getTSlibImport();
rest[0].argument = t.callExpression(
t.memberExpression(tslibImport, t.identifier("__rest")),
[arg, t.arrayExpression([t.stringLiteral("className")])]
);
rest[0].argument = call(t.memberExpression(tslibImport, id("__rest")), [
arg,
t.arrayExpression([t.stringLiteral("className")]),
]);
parent.attributes.push(
t.jsxAttribute(
t.jsxIdentifier("className"),
t.jsxExpressionContainer(
wrap(t.memberExpression(arg, t.identifier("className")))
)
jsxId("className"),
jsxBox(wrap(t.memberExpression(arg, id("className"))))
)
);
}
} else {
// Fallback
const containerValue = t.isStringLiteral(valuePathNode)
? valuePathNode
: t.callExpression(_.getCx(), [valuePathNode]);
: call(_.getCx(path.scope), [valuePathNode]);
parent.attributes.push(
t.jsxAttribute(
@ -400,7 +501,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

@ -59,7 +59,7 @@ export function handleMacro({
_.replaceWithImport({
type,
path: callee,
className: addIf(className, list.includes("group") && " group"),
className: addIf(className, addGroup(list)),
});
}
}
@ -77,12 +77,17 @@ export function handleMacro({
_.replaceWithImport({
type,
path: callee,
className: addIf(className, list.includes("group") && " group"),
className: addIf(className, addGroup(list)),
});
}
}
}
function addGroup(list: string[]) {
const groups = list.filter(name => name === "group" || name.startsWith("group/"));
return groups.length ? ` ${groups.join(" ")}` : false;
}
function addIf(text: string, suffix: string | false) {
return suffix ? text + suffix : text;
}
@ -164,32 +169,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 +231,19 @@ const supportedTags = new Set<SupportedTag>([
"sub",
"summary",
"sup",
"svg",
"table",
"tbody",
"td",
"textarea",
"tfoot",
"th",
"thead",
"time",
"tr",
"u",
"ul",
"var",
"video",
"wbr",
]);

View File

@ -1,6 +1,6 @@
import type { NodePath } from "@babel/core";
import { isPlainObject } from "lodash-es";
import invariant from "tiny-invariant";
import { type NodePath } from "@babel/core";
export function evaluateArgs(path: NodePath) {
const { confident, value } = path.evaluate();

View File

@ -1,9 +1,12 @@
import { createRequire } from "node:module";
import hash from "@emotion/hash";
import type { BabelOptions, Options as ReactOptions } from "@vitejs/plugin-react";
import { transformSync } from "esbuild";
import { memoize, without } from "lodash-es";
import type postcss from "postcss";
import type { Config } from "tailwindcss";
import type { SetRequired } from "type-fest";
import type { PluginOption } from "vite";
import { type ClassNameCollector, babelTailwind } from "./babel/index";
import { toJSCode } from "./css-to-js";
@ -21,6 +24,8 @@ export type BuildStyleFile = (
path: string
) => Promise<readonly ["css" | "local-css", string] | readonly ["js", string]>;
const require = createRequire(import.meta.url);
export interface TailwindPluginOptions {
/**
* Tailwind CSS configuration
@ -37,11 +42,6 @@ export interface TailwindPluginOptions {
*/
prefix?: string;
/**
* Additional PostCSS plugins (optional)
*/
postCSSPlugins?: postcss.AcceptedPlugin[];
/**
* Attribute to use for tailwind classes in JSX
* @default "css"
@ -55,7 +55,7 @@ export interface TailwindPluginOptions {
jsxAttributeAction?: "delete" | "preserve" | ["rename", string];
/**
* The prefix to use for the generated class names.
* The function to use to generate class names.
* @default className => `tw-${hash(className)}`
*/
getClassName?: GetClassName;
@ -65,6 +65,11 @@ export interface TailwindPluginOptions {
*/
clsx: "clsx" | "classnames" | "emotion";
/**
* Use react-aria-components `composeRenderProps` function.
*/
composeRenderProps?: boolean;
/**
* @internal
*/
@ -101,12 +106,7 @@ export interface TailwindPluginOptions {
export type ResolveTailwindOptions = SetRequired<
TailwindPluginOptions,
| "clsx"
| "jsxAttributeAction"
| "jsxAttributeName"
| "postCSSPlugins"
| "styleMap"
| "tailwindConfig"
"clsx" | "jsxAttributeAction" | "jsxAttributeName" | "styleMap" | "tailwindConfig"
>;
/**
@ -115,6 +115,15 @@ export type ResolveTailwindOptions = SetRequired<
*/
export const getClassName: GetClassName = cls => "tw-" + hash(cls);
type BabelPlugin =
| "@emotion/babel-plugin"
| "@lingui/babel-plugin-lingui-macro"
| "babel-plugin-import"
| "babel-plugin-react-compiler"
| "jotai/babel/plugin-debug-label"
| "jotai/babel/plugin-react-refresh"
| (string & {});
/**
* Main entry. Returns the plugins and utilities for processing Tailwind
* classNames in JS.
@ -137,7 +146,6 @@ export function getTailwindPlugins(options: TailwindPluginOptions) {
getClassName,
jsxAttributeAction: "delete",
jsxAttributeName: "css",
postCSSPlugins: [],
styleMap: new Map(),
tailwindConfig: {},
...options,
@ -153,7 +161,12 @@ export function getTailwindPlugins(options: TailwindPluginOptions) {
const compiled = await compile(
styles
.map(({ classNames, key }) => {
const tw = without(classNames, "group").join(" ");
const tw = without(classNames, "group", "peer")
.filter(name => !name.startsWith("group/"))
.join(" ");
if (!tw) return "";
return [
`.${key} {`,
addSourceAsComment && ` /* @preserve ${tw} */`,
@ -178,15 +191,49 @@ export function getTailwindPlugins(options: TailwindPluginOptions) {
}
};
const babel = (onCollect?: ClassNameCollector) =>
babelTailwind(resolvedOptions, onCollect);
const patchBabelOptions = (options: BabelOptions) => {
(options.plugins ??= []).push(babel());
};
const react: {
(babelPlugins: (BabelPlugin | BabelPlugin[] | false)[]): PluginOption[];
(options?: ReactOptions): PluginOption[];
} = (options: (string | string[] | false)[] | ReactOptions = {}) => {
options = Array.isArray(options)
? ({ babel: { plugins: options.flat(1).filter(Boolean) } } satisfies ReactOptions)
: options;
const reactModule = require("@vitejs/plugin-react");
const reactPlugin: typeof import("@vitejs/plugin-react").default =
"default" in reactModule ? reactModule.default : reactModule;
options.babel ??= {};
if (typeof options.babel === "function") {
const fn = options.babel;
options.babel = (id, options) => {
const result = fn(id, options);
patchBabelOptions(result);
return result;
};
} else {
patchBabelOptions(options.babel);
}
return reactPlugin(options);
};
return {
compile,
babel: (onCollect?: ClassNameCollector) => babelTailwind(resolvedOptions, onCollect),
esbuild: () => esbuildPlugin({ styleMap, compile, buildStyleFile }),
/** Requires `options.vite` to be `true`. */
vite: () => {
resolvedOptions.vite = true;
return vitePlugin({ styleMap, compile, buildStyleFile });
},
react,
styleMap,
options,
getCompiler,

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"

5
src/react-env.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare namespace React {
interface Attributes {
css?: string;
}
}

55
src/shared-v4.ts Normal file
View File

@ -0,0 +1,55 @@
import * as tailwind from "tailwindcss";
import type { ResolveTailwindOptions } from "./index";
declare const __PKG_NAME__: string;
export const pkgName = __PKG_NAME__;
export const macroNames = [`${pkgName}/macro`, `${pkgName}`];
export const utilsName = `${pkgName}/utils`;
interface LineColumn {
line: number;
column: number;
}
export interface SourceLocation {
filename: string;
start: LineColumn;
end: LineColumn;
text: string;
}
export interface StyleMapEntry {
key: string;
classNames: string[];
location: SourceLocation;
}
export const tailwindDirectives = ["components", "utilities", "variants"] as const;
export type StyleMap = Map</* filename */ string, StyleMapEntry[]>;
export function createPostCSS({
tailwindConfig,
prefix,
}: Pick<ResolveTailwindOptions, "tailwindConfig" | "prefix">) {
return async (css: string) => {
const compiler = await tailwind.compile(
[prefix, '@config "tailwind.config.js";', css].filter(Boolean).join("\n"),
{
// eslint-disable-next-line @typescript-eslint/require-await
async loadModule(_id, base, resourceHint) {
if (resourceHint === "config") {
return { base, module: tailwindConfig };
}
throw new Error(`The browser build does not support plugins or config files.`);
},
}
);
return compiler.build([]).replace(/^\/\*![^*]*\*\/\n/, "");
};
}
export type Compile = ReturnType<typeof createPostCSS>;

View File

@ -7,7 +7,7 @@ declare const __PKG_NAME__: string;
export const pkgName = __PKG_NAME__;
export const macroNames = [`${pkgName}/macro`, `${pkgName}`];
export const classedName = `${pkgName}/classed`;
export const utilsName = `${pkgName}/utils`;
interface LineColumn {
line: number;
@ -33,15 +33,13 @@ export type StyleMap = Map</* filename */ string, StyleMapEntry[]>;
export function createPostCSS({
tailwindConfig,
postCSSPlugins,
prefix,
}: Pick<ResolveTailwindOptions, "tailwindConfig" | "postCSSPlugins" | "prefix">) {
}: Pick<ResolveTailwindOptions, "tailwindConfig" | "prefix">) {
const post = postcss([
tailwind({
...tailwindConfig,
content: [{ raw: "<br>", extension: "html" }],
}),
...postCSSPlugins,
]);
return async (css: string) => {

View File

@ -67,3 +67,10 @@ export const classed: {
component.className = className;
return component;
};
export function composeClassName<T>(
value: string,
prop: string | ((values: T) => string) | undefined
) {
return typeof prop === "function" ? (arg: T) => cx(value, prop(arg)) : cx(value, prop);
}

19
src/vendor/v4.ts vendored Normal file
View File

@ -0,0 +1,19 @@
import plugin from "tailwindcss/plugin.js";
import type { PluginAPI } from "tailwindcss/types/config";
export default plugin(({ addUtilities, matchUtilities, theme }) => {
matchUtilities(
{
ms: value => ({
marginInlineStart: value,
}),
me: value => ({
marginInlineEnd: value,
}),
},
{
supportsNegativeValues: true,
values: theme("margin")!,
}
);
});

View File

@ -1,6 +1,9 @@
import { dirname, join } from "node:path";
import type * as vite from "vite";
import { type Compile, type StyleMap, macroNames, pkgName } from "./shared";
import type { BuildStyleFile } from "./index";
const ROLLUP_PREFIX = "\0tailwind:";
@ -59,4 +62,4 @@ export const vitePlugin = ({
* `babel-plugin-macros` compatible `isMacrosName` function that works with this plugin.
*/
export const isMacrosName = (v: string) =>
!macroNames.includes(v) && /[./]macro(\.c?js)?$/.test(v);
!macroNames.includes(v) && /[./]macro(?:\.c?js)?$/.test(v);