This commit is contained in:
Alex 2025-06-13 01:08:36 -04:00
parent bd683df539
commit 1d0a8a1c36
11 changed files with 1347 additions and 1285 deletions

View File

@ -1,6 +1,6 @@
{
"name": "@aet/tailwind",
"version": "1.0.32",
"version": "1.0.35",
"license": "MIT",
"type": "module",
"scripts": {
@ -36,48 +36,49 @@
}
},
"devDependencies": {
"@aet/eslint-rules": "^2.0.42",
"@aet/eslint-rules": "^2.0.52",
"@types/babel__core": "^7.20.5",
"@types/bun": "^1.2.4",
"@types/bun": "^1.2.16",
"@types/dedent": "^0.7.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.13.9",
"@types/node": "^24.0.1",
"@types/postcss-safe-parser": "^5.0.4",
"@types/react": "^19.0.10",
"@types/react": "^19.1.8",
"@types/stylis": "^4.2.7",
"@vitejs/plugin-react": "^4.3.4",
"@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.21.0",
"nolyfill": "^1.0.43",
"eslint": "^9.28.0",
"nolyfill": "^1.0.44",
"postcss-nested": "^7.0.2",
"prettier": "^3.5.3",
"react-refresh": "^0.17.0",
"tailwindcss": "^3.4.17",
"tslib": "^2.8.1",
"tsup": "^8.4.0",
"typescript": "^5.8.2",
"vite": "^6.2.0",
"vitest": "^3.0.7"
"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.9",
"@babel/core": "^7.27.4",
"@emotion/hash": "^0.9.2",
"clsx": "^2.1.1",
"esbuild": "^0.25.0",
"esbuild": "^0.25.5",
"json5": "^2.2.3",
"lodash-es": "^4.17.21",
"postcss": "^8.5.3",
"postcss": "^8.5.5",
"postcss-selector-parser": "^7.1.0",
"stylis": "^4.3.6",
"tiny-invariant": "^1.3.3",
"type-fest": "^4.37.0"
"type-fest": "^4.41.0"
},
"prettier": {
"arrowParens": "avoid",
@ -91,6 +92,9 @@
"overrides": {
"is-core-module": "npm:@nolyfill/is-core-module@^1",
"@babel/types": "7.26.7"
}
},
"onlyBuiltDependencies": [
"esbuild"
]
}
}
}

2308
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

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

@ -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

@ -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

@ -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: {},
@ -61,7 +64,10 @@ export function getBuild(name: string) {
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

@ -169,14 +169,16 @@ function getUtils({
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: cacheNode(() => {
const tslibImport = path.scope.generateUidIdentifier("tslib");
path.node.body.unshift(
path.node.body.push(
t.importDeclaration(
[t.importNamespaceSpecifier(tslibImport)],
t.stringLiteral("tslib")

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;
}

View File

@ -6,6 +6,7 @@ import { transformSync } from "esbuild";
import { memoize, without } from "lodash-es";
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";
@ -54,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;
@ -114,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.
@ -151,7 +161,12 @@ export function getTailwindPlugins(options: TailwindPluginOptions) {
const compiled = await compile(
styles
.map(({ classNames, key }) => {
const tw = without(classNames, "group", "peer").join(" ");
const tw = without(classNames, "group", "peer")
.filter(name => !name.startsWith("group/"))
.join(" ");
if (!tw) return "";
return [
`.${key} {`,
addSourceAsComment && ` /* @preserve ${tw} */`,
@ -183,6 +198,33 @@ export function getTailwindPlugins(options: TailwindPluginOptions) {
(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),
@ -191,25 +233,7 @@ export function getTailwindPlugins(options: TailwindPluginOptions) {
resolvedOptions.vite = true;
return vitePlugin({ styleMap, compile, buildStyleFile });
},
react(options: ReactOptions = {}) {
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);
},
react,
styleMap,
options,
getCompiler,

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")!,
}
);
});