Fix conditional expression

This commit is contained in:
Alex 2024-07-01 18:18:08 -04:00
parent 69cc90730c
commit 835c5b7810
6 changed files with 107 additions and 52 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "@aet/tailwind", "name": "@aet/tailwind",
"version": "0.0.1-beta.15", "version": "0.0.1-beta.20",
"main": "dist/index.js", "main": "dist/index.js",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@ -44,6 +44,7 @@
"@emotion/hash": "^0.9.1", "@emotion/hash": "^0.9.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"tiny-invariant": "^1.3.3",
"type-fest": "^4.20.1" "type-fest": "^4.20.1"
}, },
"prettier": { "prettier": {

8
pnpm-lock.yaml generated
View File

@ -20,6 +20,9 @@ importers:
postcss: postcss:
specifier: ^8.4.38 specifier: ^8.4.38
version: 8.4.38 version: 8.4.38
tiny-invariant:
specifier: ^1.3.3
version: 1.3.3
type-fest: type-fest:
specifier: ^4.20.1 specifier: ^4.20.1
version: 4.20.1 version: 4.20.1
@ -1924,6 +1927,9 @@ packages:
thenify@3.3.1: thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.6.0: tinybench@2.6.0:
resolution: {integrity: sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA==} resolution: {integrity: sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA==}
@ -3998,6 +4004,8 @@ snapshots:
dependencies: dependencies:
any-promise: 1.3.0 any-promise: 1.3.0
tiny-invariant@1.3.3: {}
tinybench@2.6.0: {} tinybench@2.6.0: {}
tinypool@0.8.3: {} tinypool@0.8.3: {}

View File

@ -2,6 +2,7 @@ import { basename, dirname, extname, join } from "node:path";
import type babel from "@babel/core"; import type babel from "@babel/core";
import hash from "@emotion/hash"; import hash from "@emotion/hash";
import { isPlainObject } from "lodash"; import { isPlainObject } from "lodash";
import invariant from "tiny-invariant";
import { type NodePath, type types as t } from "@babel/core"; import { type NodePath, type types as t } from "@babel/core";
import type { SourceLocation, StyleMapEntry } from "./shared"; import type { SourceLocation, StyleMapEntry } from "./shared";
import { type ResolveTailwindOptions, getClassName } from "./index"; import { type ResolveTailwindOptions, getClassName } from "./index";
@ -37,6 +38,15 @@ interface BabelPluginState {
export type ClassNameCollector = (path: string, entries: StyleMapEntry[]) => void; export type ClassNameCollector = (path: string, entries: StyleMapEntry[]) => void;
const trim = (value: string) => value.replace(/\s+/g, " ").trim(); const trim = (value: string) => value.replace(/\s+/g, " ").trim();
const trimPrefix = (cls: string, prefix: string) =>
trim(cls)
.split(" ")
.map(value => prefix + value);
const flatMapEntries = <K extends string | number, V, R>(
map: Record<K, V>,
fn: (value: V, key: K) => R[]
): R[] => Object.entries(map).flatMap(([key, value]) => fn(value as V, key as K));
export function babelTailwind( export function babelTailwind(
{ {
@ -76,30 +86,31 @@ export function babelTailwind(
return paths return paths
.flatMap(path => { .flatMap(path => {
const { confident, value } = path.evaluate(); const { confident, value } = path.evaluate();
if (!confident) { invariant(confident, `${macroFunction} argument cannot be statically evaluated`);
throw new Error(`${macroFunction} argument cannot be statically evaluated`);
}
if (typeof value === "string") { if (typeof value === "string") {
return trim(value); return trim(value);
} }
if (isPlainObject(value)) { if (isPlainObject(value)) {
return Object.entries(value).flatMap(([modifier, classes]) => { return flatMapEntries(value, (classes, modifier) => {
if (modifier === "data" && isPlainObject(classes)) { if (modifier === "data" && isPlainObject(classes)) {
return Object.entries(classes as object).flatMap(([key, cls]) => return flatMapEntries(
trim(cls) classes as Record<string, string | object>,
.split(" ") (cls, key) =>
.map(value => `${modifier}-[${key}]:${value}`) typeof cls === "string"
? trimPrefix(cls, `${modifier}-[${key}]:`)
: flatMapEntries(cls as Record<string, string>, (cls, attrValue) =>
trimPrefix(cls, `${modifier}-[${key}=${attrValue}]:`)
)
); );
} }
if (typeof classes !== "string") { invariant(
throw new Error(`Value for "${modifier}" should be a string`); typeof classes === "string",
} `Value for "${modifier}" should be a string`
return trim(classes) );
.split(" ") return trimPrefix(classes, modifier + ":");
.map(cls => modifier + ":" + cls);
}); });
} }
@ -138,9 +149,7 @@ export function babelTailwind(
exit({ node }, { filename, tailwindMap }) { exit({ node }, { filename, tailwindMap }) {
if (!tailwindMap.size) return; if (!tailwindMap.size) return;
if (!filename) { invariant(filename, "babel: missing state.filename");
throw new Error("babel: missing state.filename");
}
const cssName = basename(filename, extname(filename)) + ".css"; const cssName = basename(filename, extname(filename)) + ".css";
@ -170,9 +179,10 @@ export function babelTailwind(
} = node; } = node;
if (!t.isIdentifier(tag, { name: macroFunction })) return; if (!t.isIdentifier(tag, { name: macroFunction })) return;
if (expressions.length) { invariant(
throw new Error(`${macroFunction}\`\` should not contain expressions`); !expressions.length,
} `${macroFunction}\`\` should not contain expressions`
);
const value = quasis[0].value.cooked; const value = quasis[0].value.cooked;
if (value) { if (value) {
@ -225,9 +235,8 @@ export function babelTailwind(
StringLiteral(path) { StringLiteral(path) {
const { node } = path; const { node } = path;
const { value } = node; const { value } = node;
const trimmed = trim(value);
if (value) { if (trimmed) {
const trimmed = trim(value);
const className = getClass(trimmed); const className = getClass(trimmed);
recordIfAbsent({ recordIfAbsent({
key: className, key: className,
@ -304,7 +313,7 @@ export function babelTailwind(
parent.attributes.push( parent.attributes.push(
t.jsxAttribute( t.jsxAttribute(
t.jsxIdentifier("className"), t.jsxIdentifier("className"),
valuePathNode as (typeof valuePath)["node"] t.jSXExpressionContainer(valuePathNode!)
) )
); );
} }

View File

@ -1,10 +1,17 @@
import cx from "clsx"; import cx from "clsx";
import { type FunctionComponent, forwardRef } from "react"; import { type FunctionComponent, forwardRef } from "react";
interface WithClassName<P = object> extends FunctionComponent<P> { interface WithClassName<Props = object> extends FunctionComponent<Props> {
className: string; className: string;
} }
type PresetClassNameValue = string | string[];
type PresetClassName<Props = UnknownProps> =
| PresetClassNameValue
| ((props: Props) => PresetClassNameValue);
type UnknownProps = Record<string, unknown>;
type InputProps = React.DetailedHTMLProps< type InputProps = React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>, React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement HTMLInputElement
@ -13,35 +20,38 @@ type InputProps = React.DetailedHTMLProps<
export const classed: { export const classed: {
( (
type: "input", type: "input",
className: string | string[], className: PresetClassName<InputProps>,
defaultProps?: Partial<InputProps> defaultProps?: Partial<InputProps>
): InputProps; ): InputProps;
<K extends keyof JSX.IntrinsicElements>( <K extends keyof JSX.IntrinsicElements>(
type: K, type: K,
className: string | string[], className: PresetClassName<JSX.IntrinsicElements[K]>,
defaultProps?: Partial<JSX.IntrinsicElements[K]> defaultProps?: Partial<JSX.IntrinsicElements[K]>
): JSX.IntrinsicElements[K]; ): JSX.IntrinsicElements[K];
( (
type: string, type: string,
className: string | string[], className: PresetClassName,
defaultProps?: Record<string, unknown> defaultProps?: UnknownProps
): WithClassName<React.ClassAttributes<any> & React.DOMAttributes<any>>; ): WithClassName<React.ClassAttributes<any> & React.DOMAttributes<any>>;
<P>( <Props>(
type: FunctionComponent<P>, type: FunctionComponent<Props>,
className: string | string[], className: PresetClassName<Props>,
defaultProps?: Partial<P> defaultProps?: Partial<Props>
): WithClassName<P>; ): WithClassName<Props>;
<P>( <Props>(
type: React.ComponentClass<P>, type: React.ComponentClass<Props>,
className: string | string[], className: PresetClassName<Props>,
defaultProps?: Partial<P> defaultProps?: Partial<Props>
): WithClassName<P>; ): WithClassName<Props>;
} = ( } = (
Component: any, Component: any,
classNameInput: string | string[], classNameInput: PresetClassName<any>,
defaultProps?: Record<string, unknown> defaultProps?: UnknownProps
) => { ) => {
const className = cx(classNameInput); const className =
typeof classNameInput === "function"
? (props: any) => cx(classNameInput(props))
: () => cx(classNameInput);
const component: any = forwardRef<any, any>(({ className: cls, ...props }, ref) => ( const component: any = forwardRef<any, any>(({ className: cls, ...props }, ref) => (
<Component <Component
{...defaultProps} {...defaultProps}
@ -49,8 +59,8 @@ export const classed: {
ref={ref} ref={ref}
className={ className={
typeof cls === "function" typeof cls === "function"
? (...args: unknown[]) => cx(className, cls(...args)) ? (...args: unknown[]) => cx(className(props), cls(...args))
: cx(className, cls) : cx(className(props), cls)
} }
/> />
)); ));

View File

@ -169,13 +169,16 @@ describe("babel-tailwind", () => {
"[&>div]": \`font-semibold\`, "[&>div]": \`font-semibold\`,
data: { data: {
"name='hello'": "text-right", "name='hello'": "text-right",
nested: {
true: "border",
}
}, },
}) })
`, `,
}); });
const clsName = getClassName( const clsName = getClassName(
"text-sm flex group-hover:text-center [&>div]:font-semibold data-[name='hello']:text-right" "text-sm flex group-hover:text-center [&>div]:font-semibold data-[name='hello']:text-right data-[nested=true]:border"
); );
expect(files.js.text).toContain(`= "${clsName}"`); expect(files.js.text).toContain(`= "${clsName}"`);
expect(files.css.text).toMatch( expect(files.css.text).toMatch(
@ -188,6 +191,9 @@ describe("babel-tailwind", () => {
`.group:hover .${clsName} {`, `.group:hover .${clsName} {`,
" text-align: center;", " text-align: center;",
"}", "}",
`.${clsName}[data-nested=true] {`,
" border-width: 1px;",
"}",
`.${clsName}[data-name=hello] {`, `.${clsName}[data-name=hello] {`,
" text-align: right;", " text-align: right;",
"}", "}",
@ -227,6 +233,26 @@ describe("babel-tailwind", () => {
); );
}); });
it('supports conditional expression in "css" attribute', async () => {
const { files } = await compileESBuild({
clsx: "emotion",
expectFiles: 2,
javascript: /* tsx */ `
export function Hello({ isCenter }) {
return (
<div css={isCenter ? "text-center" : undefined}>
Hello, world!
</div>
);
}
`,
});
const clsName = getClassName("text-center");
expect(files.js.text).toContain(`className: isCenter ? "${clsName}" : void 0`);
expect(files.css.text).toMatch(`.${clsName} {\n text-align: center;\n}`);
});
it("supports importing tailwind/base", async () => { it("supports importing tailwind/base", async () => {
const postcss = createPostCSS({ const postcss = createPostCSS({
tailwindConfig: {}, tailwindConfig: {},

View File

@ -13,18 +13,19 @@ export { createPostCSS } from "./shared";
type GetClassName = (className: string) => string; type GetClassName = (className: string) => string;
interface RecursiveStringObject {
[modifier: string]: string | RecursiveStringObject;
}
export type CSSAttributeValue = string | (string | RecursiveStringObject)[];
/** /**
* Tagged template macro function for Tailwind classes * Tagged template macro function for Tailwind classes
* @example "tw" => tw`p-2 text-center` * @example "tw" => tw`p-2 text-center`
*/ */
export interface TailwindFunction { export interface TailwindFunction {
(strings: TemplateStringsArray): string; (strings: TemplateStringsArray): string;
( (...args: (string | RecursiveStringObject)[]): string;
...args: (
| string
| ({ data?: { [key: string]: string } } & { [modifier: string]: string })
)[]
): string;
} }
export interface TailwindPluginOptions { export interface TailwindPluginOptions {