composeRenderProps
This commit is contained in:
@ -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>;
|
||||
}
|
||||
|
18
package.json
18
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@aet/tailwind",
|
||||
"version": "1.0.25",
|
||||
"version": "1.0.28",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@ -30,10 +30,13 @@
|
||||
"./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.36",
|
||||
"@aet/eslint-rules": "^2.0.36",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/bun": "^1.2.2",
|
||||
"@types/dedent": "^0.7.2",
|
||||
@ -42,13 +45,14 @@
|
||||
"@types/postcss-safe-parser": "^5.0.4",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/stylis": "^4.2.7",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"cli-highlight": "^2.1.11",
|
||||
"clsx": "^2.1.1",
|
||||
"colord": "^2.9.3",
|
||||
"css-what": "^6.1.0",
|
||||
"dedent": "^1.5.3",
|
||||
"esbuild-register": "^3.6.0",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint": "^9.20.0",
|
||||
"nolyfill": "^1.0.43",
|
||||
"postcss-nested": "^7.0.2",
|
||||
"prettier": "^3.4.2",
|
||||
@ -63,14 +67,14 @@
|
||||
"tailwindcss": "^3.4.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.26.7",
|
||||
"@babel/core": "^7.26.8",
|
||||
"@emotion/hash": "^0.9.2",
|
||||
"esbuild": "^0.24.2",
|
||||
"esbuild": "^0.25.0",
|
||||
"json5": "^2.2.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"postcss": "^8.5.1",
|
||||
"postcss-selector-parser": "^7.0.0",
|
||||
"stylis": "^4.3.5",
|
||||
"postcss-selector-parser": "^7.1.0",
|
||||
"stylis": "^4.3.6",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"type-fest": "^4.33.0"
|
||||
},
|
||||
|
839
pnpm-lock.yaml
generated
839
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
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" };
|
||||
@ -57,28 +56,13 @@ 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"),
|
||||
fs.copyFile("src/react-env.d.ts", "dist/react-env.d.ts"),
|
||||
Bun.write(`dist/base.d.ts`, `/**\n * \`@tailwind base\` component.\n */\nexport {};`),
|
||||
]);
|
||||
|
||||
|
@ -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}",`);
|
||||
});
|
||||
});
|
||||
|
@ -50,6 +50,31 @@ describe("merges with existing className attribute", () => {
|
||||
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(
|
||||
`import { composeRenderProps as _composeRenderProps } from "react-aria-component";`
|
||||
);
|
||||
expect(files.js.text).toContain(
|
||||
`...props, className: _composeRenderProps(_className, (n) => _cx("${clsName}", n)),`
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses clsx in scope", async () => {
|
||||
const { files } = await compileESBuild({
|
||||
clsx: "clsx",
|
||||
|
@ -52,6 +52,7 @@ export function getBuild(name: string) {
|
||||
external: [
|
||||
"react",
|
||||
"react/jsx-runtime",
|
||||
"react-aria-component",
|
||||
"@emotion/css",
|
||||
"clsx",
|
||||
"tslib",
|
||||
|
@ -53,6 +53,7 @@ function getUtils({
|
||||
let styleImport: t.Identifier;
|
||||
let classedImport: t.Identifier;
|
||||
let cssModuleImport: t.Identifier;
|
||||
let composeRenderPropsImport: t.Identifier;
|
||||
|
||||
const cssMap = new Map<string, StyleMapEntry>();
|
||||
const jsMap = new Map<string, StyleMapEntry>();
|
||||
@ -99,11 +100,14 @@ 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);
|
||||
function reuseImport(scope: Scope) {
|
||||
if (
|
||||
existingCx &&
|
||||
scope.getBinding(existingCx) === path.scope.getBinding(existingCx)
|
||||
) {
|
||||
return t.identifier(existingCx);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
program: path,
|
||||
@ -151,7 +155,7 @@ function getUtils({
|
||||
|
||||
getCx: (localScope: Scope) => {
|
||||
if (cx == null) {
|
||||
const reuse = reuseImport(localScope, existingCx);
|
||||
const reuse = reuseImport(localScope);
|
||||
if (reuse) return reuse;
|
||||
|
||||
cx = path.scope.generateUidIdentifier("cx");
|
||||
@ -173,6 +177,24 @@ function getUtils({
|
||||
return t.cloneNode(tslibImport);
|
||||
},
|
||||
|
||||
getComposeRenderPropsImport: () => {
|
||||
if (composeRenderPropsImport == null) {
|
||||
composeRenderPropsImport = path.scope.generateUidIdentifier("composeRenderProps");
|
||||
path.node.body.unshift(
|
||||
t.importDeclaration(
|
||||
[
|
||||
t.importSpecifier(
|
||||
composeRenderPropsImport,
|
||||
t.identifier("composeRenderProps")
|
||||
),
|
||||
],
|
||||
t.stringLiteral("react-aria-component")
|
||||
)
|
||||
);
|
||||
}
|
||||
return t.cloneNode(composeRenderPropsImport);
|
||||
},
|
||||
|
||||
getClassedImport: () => {
|
||||
if (classedImport == null) {
|
||||
classedImport = path.scope.generateUidIdentifier("classed");
|
||||
@ -256,6 +278,7 @@ export function babelTailwind(
|
||||
getClassName: getClass = getClassName,
|
||||
jsxAttributeAction = "delete",
|
||||
jsxAttributeName = "css",
|
||||
composeRenderProps,
|
||||
} = options;
|
||||
|
||||
return definePlugin<BabelPluginUtils>(({ types: t }) => ({
|
||||
@ -345,6 +368,7 @@ export function babelTailwind(
|
||||
);
|
||||
}
|
||||
|
||||
// There is an existing className attribute
|
||||
if (classNameAttribute) {
|
||||
const attrValue = classNameAttribute.value!;
|
||||
const wrap = (...originalValue: (b.types.Expression | b.types.SpreadElement)[]) =>
|
||||
@ -355,60 +379,80 @@ export function babelTailwind(
|
||||
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(internalAttrValue) &&
|
||||
t.isIdentifier(internalAttrValue.callee) &&
|
||||
t.isCallExpression(internal) &&
|
||||
t.isIdentifier(internal.callee) &&
|
||||
_.existingCx &&
|
||||
_.program.scope
|
||||
.getBinding(_.existingCx)
|
||||
?.referencePaths.map(p => p.node)
|
||||
.includes(internalAttrValue.callee)
|
||||
.includes(internal.callee)
|
||||
) {
|
||||
classNameAttribute.value = t.jsxExpressionContainer(
|
||||
wrap(
|
||||
...(internalAttrValue.arguments as (
|
||||
| b.types.Expression
|
||||
| b.types.SpreadElement
|
||||
)[])
|
||||
...(internal.arguments as (b.types.Expression | b.types.SpreadElement)[])
|
||||
)
|
||||
);
|
||||
} else {
|
||||
classNameAttribute.value = t.jsxExpressionContainer(wrap(internalAttrValue));
|
||||
classNameAttribute.value = t.jsxExpressionContainer(wrap(internal));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const wrap = (originalValue: b.types.Expression) =>
|
||||
t.callExpression(_.getCx(path.scope), [valuePathNode, originalValue]);
|
||||
composeRenderProps
|
||||
? // composeRenderProps(className, n => cn("...", n))
|
||||
t.callExpression(_.getComposeRenderPropsImport(), [
|
||||
originalValue,
|
||||
t.arrowFunctionExpression(
|
||||
[t.identifier("n")],
|
||||
t.callExpression(_.getCx(path.scope), [
|
||||
valuePathNode,
|
||||
t.identifier("n"),
|
||||
])
|
||||
),
|
||||
])
|
||||
: t.callExpression(_.getCx(path.scope), [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.restElement(node),
|
||||
]);
|
||||
} else {
|
||||
// ({ ...props }) => ...
|
||||
// ↪ ({ className, ...props }) => ...
|
||||
node.properties.unshift(
|
||||
t.objectProperty(t.identifier("className"), clsVar)
|
||||
);
|
||||
@ -437,6 +481,7 @@ export function babelTailwind(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Fallback
|
||||
const containerValue = t.isStringLiteral(valuePathNode)
|
||||
? valuePathNode
|
||||
: t.callExpression(_.getCx(path.scope), [valuePathNode]);
|
||||
|
51
src/index.ts
51
src/index.ts
@ -1,7 +1,9 @@
|
||||
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";
|
||||
|
||||
@ -21,6 +23,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 +41,6 @@ export interface TailwindPluginOptions {
|
||||
*/
|
||||
prefix?: string;
|
||||
|
||||
/**
|
||||
* Additional PostCSS plugins (optional)
|
||||
*/
|
||||
postCSSPlugins?: postcss.AcceptedPlugin[];
|
||||
|
||||
/**
|
||||
* Attribute to use for tailwind classes in JSX
|
||||
* @default "css"
|
||||
@ -65,6 +64,11 @@ export interface TailwindPluginOptions {
|
||||
*/
|
||||
clsx: "clsx" | "classnames" | "emotion";
|
||||
|
||||
/**
|
||||
* Use react-aria-component’s `composeRenderProps` function.
|
||||
*/
|
||||
composeRenderProps?: boolean;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
@ -101,12 +105,7 @@ export interface TailwindPluginOptions {
|
||||
|
||||
export type ResolveTailwindOptions = SetRequired<
|
||||
TailwindPluginOptions,
|
||||
| "clsx"
|
||||
| "jsxAttributeAction"
|
||||
| "jsxAttributeName"
|
||||
| "postCSSPlugins"
|
||||
| "styleMap"
|
||||
| "tailwindConfig"
|
||||
"clsx" | "jsxAttributeAction" | "jsxAttributeName" | "styleMap" | "tailwindConfig"
|
||||
>;
|
||||
|
||||
/**
|
||||
@ -137,7 +136,6 @@ export function getTailwindPlugins(options: TailwindPluginOptions) {
|
||||
getClassName,
|
||||
jsxAttributeAction: "delete",
|
||||
jsxAttributeName: "css",
|
||||
postCSSPlugins: [],
|
||||
styleMap: new Map(),
|
||||
tailwindConfig: {},
|
||||
...options,
|
||||
@ -178,15 +176,40 @@ export function getTailwindPlugins(options: TailwindPluginOptions) {
|
||||
}
|
||||
};
|
||||
|
||||
const babel = (onCollect?: ClassNameCollector) =>
|
||||
babelTailwind(resolvedOptions, onCollect);
|
||||
|
||||
const patchBabelOptions = (options: BabelOptions) => {
|
||||
(options.plugins ??= []).push(babel());
|
||||
};
|
||||
|
||||
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(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);
|
||||
},
|
||||
styleMap,
|
||||
options,
|
||||
getCompiler,
|
||||
|
5
src/react-env.d.ts
vendored
Normal file
5
src/react-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
declare namespace React {
|
||||
interface Attributes {
|
||||
css?: string;
|
||||
}
|
||||
}
|
55
src/shared-v4.ts
Normal file
55
src/shared-v4.ts
Normal 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 classedName = `${pkgName}/classed`;
|
||||
|
||||
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>;
|
@ -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) => {
|
||||
|
@ -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:";
|
||||
|
Reference in New Issue
Block a user