From d4404f7ae277cd658887f1d4be182e6d437536c2 Mon Sep 17 00:00:00 2001 From: Alex <8125011+alex-kinokon@users.noreply.github.com> Date: Sat, 6 Jul 2024 02:49:25 -0400 Subject: [PATCH] Update --- package.json | 2 +- scripts/index.ts | 2 +- src/__tests__/styleObject.test.ts | 47 ++++++++++ src/__tests__/tw.test.ts | 18 ++++ src/__tests__/utils.ts | 2 - src/babel-tailwind.ts | 141 +++++++++++++++++++----------- src/classed.tsx | 6 +- src/esbuild-postcss.ts | 83 +++++++++--------- src/index.ts | 9 +- src/macro.d.ts | 62 ++++++++++++- src/shared.ts | 14 +-- 11 files changed, 267 insertions(+), 119 deletions(-) diff --git a/package.json b/package.json index 1539054..cf3eaec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aet/tailwind", - "version": "0.0.1-beta.30", + "version": "0.0.1-beta.33", "license": "MIT", "scripts": { "build": "./scripts/index.ts", diff --git a/scripts/index.ts b/scripts/index.ts index 1c5ded4..3219ada 100755 --- a/scripts/index.ts +++ b/scripts/index.ts @@ -38,13 +38,13 @@ await Promise.all([ 2 ) ), - Bun.write(`dist/base.d.ts`, `/**\n * \`@tailwind base\` component.\n */\nexport {};`), ]); 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 {};`), ]); process.exit(0); diff --git a/src/__tests__/styleObject.test.ts b/src/__tests__/styleObject.test.ts index a0472bd..4452db6 100644 --- a/src/__tests__/styleObject.test.ts +++ b/src/__tests__/styleObject.test.ts @@ -42,4 +42,51 @@ describe("babel-tailwind", () => { ); expect(files.js.text).toContain(`style: ${clsName}`); }); + + it("supports .hover, .focus, .active, .group-hover, .group-focus, .group-active", async () => { + const { files } = await compileESBuild({ + clsx: "emotion", + expectFiles: 1, + javascript: ` + import { tws } from "@aet/tailwind/macro"; + + export const style = [ + tws.hover\`font-semibold\`, + tws.focus\`font-bold\`, + tws.active\`font-light\`, + tws.hover.active\`p-2\`, + ]; + `, + }); + + const semibold = getClassName("hover:font-semibold").replace(/^tw-/, "tw_"); + const bold = getClassName("focus:font-bold").replace(/^tw-/, "tw_"); + const light = getClassName("active:font-light").replace(/^tw-/, "tw_"); + const p = getClassName("active:hover:p-2").replace(/^tw-/, "tw_"); + + expect(files.js.text).toContain( + [ + `var ${bold} = {`, + ' "&:focus": {', + ' fontWeight: "700"', + " }", + "};", + `var ${light} = {`, + ' "&:active": {', + ' fontWeight: "300"', + " }", + "};", + `var ${p} = {`, + ' "&:hover:active": {', + ' padding: "0.5rem"', + " }", + "};", + `var ${semibold} = {`, + ' "&:hover": {', + ' fontWeight: "600"', + " }", + "};", + ].join("\n") + ); + }); }); diff --git a/src/__tests__/tw.test.ts b/src/__tests__/tw.test.ts index 053cea5..6b50466 100644 --- a/src/__tests__/tw.test.ts +++ b/src/__tests__/tw.test.ts @@ -51,4 +51,22 @@ describe("babel-tailwind", () => { ].join("\n") ); }); + + it("passes through `group` className", async () => { + const { files } = await compileESBuild({ + clsx: "emotion", + expectFiles: 2, + javascript: ` + import { tw } from "@aet/tailwind/macro"; + + export default tw\`group hover:text-center\`; + `, + }); + + const clsName = getClassName("group hover:text-center"); + expect(files.js.text).toContain(`"${clsName} group"`); + expect(files.css.text).toMatch( + [`.${clsName}:hover {`, " text-align: center;", "}"].join("\n") + ); + }); }); diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts index 3e376d0..1760e8c 100644 --- a/src/__tests__/utils.ts +++ b/src/__tests__/utils.ts @@ -42,8 +42,6 @@ export function getBuild(name: string) { }) { const tailwind = getTailwindPlugins({ tailwindConfig: {}, - macroFunction: "tw", - macroStyleFunction: "tws", ...options, }); const result = await esbuild.build({ diff --git a/src/babel-tailwind.ts b/src/babel-tailwind.ts index 254d076..51057e0 100644 --- a/src/babel-tailwind.ts +++ b/src/babel-tailwind.ts @@ -1,5 +1,5 @@ import { basename, dirname, extname, join } from "node:path"; -import type babel from "@babel/core"; +import type b from "@babel/core"; import hash from "@emotion/hash"; import { isPlainObject } from "lodash"; import invariant from "tiny-invariant"; @@ -8,23 +8,25 @@ import { type SourceLocation, type StyleMapEntry, macroName } from "./shared"; import { type ResolveTailwindOptions, getClassName } from "./index"; export type ClassNameCollector = (path: string, entries: StyleMapEntry[]) => void; -type BabelTypes = typeof babel.types; +type BabelTypes = typeof b.types; type Type = "css" | "js"; export function babelTailwind( - { + options: ResolveTailwindOptions, + onCollect: ClassNameCollector | undefined +) { + const { styleMap, clsx, getClassName: getClass = getClassName, jsxAttributeAction = "delete", jsxAttributeName = "css", - vite, - }: ResolveTailwindOptions, - onCollect: ClassNameCollector | undefined -) { - type BabelPluginState = ReturnType; + vite: bustCache, + } = options; - function getState(path: NodePath, state: babel.PluginPass, t: BabelTypes) { + type BabelPluginUtils = ReturnType; + + function getUtils(path: NodePath, state: b.PluginPass, t: BabelTypes) { let cx: t.Identifier; let styleImport: t.Identifier; @@ -58,7 +60,15 @@ export function babelTailwind( } }, - replaceWithImport(type: Type, path: NodePath, className: string) { + replaceWithImport({ + type, + path, + className, + }: { + type: Type; + path: NodePath; + className: string; + }) { if (type === "css") { path.replaceWith(t.stringLiteral(className)); } else { @@ -86,7 +96,7 @@ export function babelTailwind( const cssName = basename(filename, extname(filename)) + ".css"; const path = join(dirname(filename), cssName); const value = Array.from(cssMap.values()); - const importee = `tailwind:./${cssName}` + getSuffix(vite, value); + const importee = `tailwind:./${cssName}` + getSuffix(bustCache, value); node.body.unshift(t.importDeclaration([], t.stringLiteral(importee))); @@ -98,7 +108,7 @@ export function babelTailwind( const jsName = basename(filename, extname(filename)) + ".tailwindStyle.js"; const path = join(dirname(filename), jsName); const value = Array.from(jsMap.values()); - const importee = `tailwind:./${jsName}` + getSuffix(vite, value); + const importee = `tailwind:./${jsName}` + getSuffix(bustCache, value); node.body.unshift( t.importDeclaration( @@ -113,23 +123,21 @@ export function babelTailwind( }; } - return definePlugin(({ types: t }) => ({ + return definePlugin(({ types: t }) => ({ Program: { enter(path, state) { - const _ = getState(path, state, t); + const _ = getUtils(path, state, t); Object.assign(state, _); - for (const { - local: { parentPath: local }, - imported, - } of getMacros(t, path, macroName)) { + for (const { callee, imported, prefix } of getMacros(t, path, macroName).map( + macro => mapMacro(t, macro) + )) { const type = imported === "tw" ? "css" : imported === "tws" ? "js" : undefined; if (!type) continue; - if (isNodePath(local, t.isTaggedTemplateExpression)) { - const { node } = local; - const { tag, quasi } = node; - if (!t.isIdentifier(tag)) continue; + if (isNodePath(callee, t.isTaggedTemplateExpression)) { + const { node } = callee; + const { quasi } = node; invariant( !quasi.expressions.length, @@ -138,28 +146,35 @@ export function babelTailwind( const value = quasi.quasis[0].value.cooked; if (value) { - const trimmed = trim(value); - const className = _.getClass(type, trimmed); + const list = trimPrefix(value, prefix ? prefix + ":" : undefined); + const className = _.getClass(type, list.join(" ")); _.recordIfAbsent(type, { key: className, - className: trimmed, + classNames: list, location: _.sliceText(node), }); - _.replaceWithImport(type, local, className); + _.replaceWithImport({ + type, + path: callee, + className: addIf(className, list.includes("group") && " group"), + }); } - } else if (isNodePath(local, t.isCallExpression)) { - const { node } = local; - const { callee } = node; - if (!t.isIdentifier(callee)) continue; + } else if (isNodePath(callee, t.isCallExpression)) { + const { node } = callee; + if (!t.isIdentifier(node.callee)) continue; - const trimmed = local.get("arguments").flatMap(evaluateArgs).join(" "); - const className = getClass(trimmed); + const list = callee.get("arguments").flatMap(evaluateArgs); + const className = getClass(list.join(" ")); _.recordIfAbsent(type, { key: className, - className: trimmed, + classNames: list, location: _.sliceText(node), }); - _.replaceWithImport(type, local, className); + _.replaceWithImport({ + type, + path: callee, + className: addIf(className, list.includes("group") && " group"), + }); } } }, @@ -190,11 +205,11 @@ export function babelTailwind( const { node } = path; const { value } = node; const trimmed = trim(value); - if (trimmed) { - const className = getClass(trimmed); + if (trimmed.length) { + const className = getClass(trimmed.join(" ")); _.recordIfAbsent("css", { key: className, - className: trimmed, + classNames: trimmed, location: _.sliceText(node), }); path.replaceWith(t.stringLiteral(className)); @@ -206,11 +221,11 @@ export function babelTailwind( } }, ObjectExpression(path) { - const trimmed = evaluateArgs(path).join(" "); - const className = getClass(trimmed); + const trimmed = evaluateArgs(path); + const className = getClass(trimmed.join(" ")); _.recordIfAbsent("css", { key: className, - className: trimmed, + classNames: trimmed, location: _.sliceText(path.node), }); path.replaceWith(t.stringLiteral(className)); @@ -245,7 +260,7 @@ export function babelTailwind( if (classNameAttribute) { const attrValue = classNameAttribute.value!; - const wrap = (originalValue: babel.types.Expression) => + const wrap = (originalValue: b.types.Expression) => t.callExpression(_.getCx(), [originalValue, valuePathNode]); // If both are string literals, we can merge them directly here @@ -308,7 +323,7 @@ function evaluateArgs(path: NodePath) { invariant(confident, "Argument cannot be statically evaluated"); if (typeof value === "string") { - return [trim(value)]; + return trim(value); } if (isPlainObject(value)) { @@ -384,13 +399,32 @@ function getMacros( return macros; } +function mapMacro(t: BabelTypes, macro: ReturnType[number]) { + let callee = macro.local.parentPath; + const prefix: string[] = []; + + while (isNodePath(callee, t.isMemberExpression)) { + invariant(t.isIdentifier(callee.node.property), "Invalid member expression"); + prefix.unshift( + callee.node.property.name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase() + ); + callee = callee.parentPath; + } + + return { + callee, + imported: macro.imported, + prefix: prefix.length ? prefix.join(":") : undefined, + }; +} + const definePlugin = - (fn: (runtime: typeof babel) => babel.Visitor) => - (runtime: typeof babel) => { - const plugin: babel.PluginObj = { + (fn: (runtime: typeof b) => b.Visitor) => + (runtime: typeof b) => { + const plugin: b.PluginObj = { visitor: fn(runtime), }; - return plugin as babel.PluginObj; + return plugin as b.PluginObj; }; const extractJSXContainer = (attr: NonNullable): t.Expression => @@ -398,13 +432,17 @@ const extractJSXContainer = (attr: NonNullable): t.Expr function matchPath( nodePath: NodePath, - fns: (dig: (nodePath: NodePath) => void) => babel.Visitor + fns: (dig: (nodePath: NodePath) => void) => b.Visitor ) { if (!nodePath.node) return; const fn = fns(path => matchPath(path, fns))[nodePath.node.type] as any; fn?.(nodePath); } +function addIf(text: string, suffix: string | false) { + return suffix ? text + suffix : text; +} + const isNodePath = ( nodePath: NodePath | null, predicate: (node: t.Node) => node is T @@ -413,15 +451,12 @@ const isNodePath = ( function getSuffix(add: boolean | undefined, entries: StyleMapEntry[]) { if (!add) return ""; - const cacheKey = hash(entries.map(x => x.className).join(",")); + const cacheKey = hash(entries.map(x => x.classNames).join(",")); return `?${cacheKey}`; } -const trim = (value: string) => value.replace(/\s+/g, " ").trim(); -const trimPrefix = (cls: string, prefix: string) => - trim(cls) - .split(" ") - .map(value => prefix + value); +const trim = (value: string) => value.replace(/\s+/g, " ").trim().split(" "); +const trimPrefix = (cls: string, prefix = "") => trim(cls).map(value => prefix + value); const flatMapEntries = ( map: Record, diff --git a/src/classed.tsx b/src/classed.tsx index abe5b08..eb5be4a 100644 --- a/src/classed.tsx +++ b/src/classed.tsx @@ -1,5 +1,5 @@ -import cx from "clsx"; import { type FunctionComponent, forwardRef } from "react"; +import cx from "clsx"; interface WithClassName extends FunctionComponent { className: string; @@ -22,12 +22,12 @@ export const classed: { type: "input", className: PresetClassName, defaultProps?: Partial - ): InputProps; + ): React.FunctionComponent; ( type: K, className: PresetClassName, defaultProps?: Partial - ): JSX.IntrinsicElements[K]; + ): React.FunctionComponent; ( type: string, className: PresetClassName, diff --git a/src/esbuild-postcss.ts b/src/esbuild-postcss.ts index c511a10..a88d77d 100644 --- a/src/esbuild-postcss.ts +++ b/src/esbuild-postcss.ts @@ -1,7 +1,7 @@ import { dirname, join } from "node:path"; import type * as esbuild from "esbuild"; import { CssSyntaxError } from "postcss"; -import { type Compile, type StyleMap, pkgName } from "./shared"; +import { type Compile, type StyleMap, type StyleMapEntry, pkgName } from "./shared"; import type { BuildStyleFile } from "./index"; const PLUGIN_NAME = "tailwind"; @@ -43,7 +43,6 @@ export const esbuildPlugin = ({ } if (!styleMap.has(path)) return; - const styles = styleMap.get(path)!; try { @@ -51,47 +50,51 @@ export const esbuildPlugin = ({ return { contents, loader }; } catch (e) { if (e instanceof CssSyntaxError) { - const lines = e.source!.split("\n"); - const cls = lines - .at(e.line! - 2)! - .slice(1, -1) - .trim(); - - const entry = styles.find(s => s.key === cls)!; - if (!entry) { - console.error(e); - throw new Error("Could not find entry for CSS"); - } - - const { location: loc } = entry; - const errLoc: Partial = { - file: loc.filename, - line: loc.start.line, - column: loc.start.column, - length: loc.end.column - loc.start.column, - lineText: loc.text, - }; - - const doesNotExist = e.reason.match(/The `(.+)` class does not exist/)?.[1]; - if (doesNotExist) { - const index = loc.text.indexOf(doesNotExist, loc.start.column); - if (index !== -1) { - errLoc.column = index; - errLoc.length = doesNotExist.length; - } - } - - return { - errors: [ - { - text: e.reason, - location: errLoc, - }, - ], - }; + return buildError(e, styles); } throw e; } }); }, }); + +function buildError(e: CssSyntaxError, styles: StyleMapEntry[]) { + const lines = e.source!.split("\n"); + const cls = lines + .at(e.line! - 2)! + .slice(1, -1) + .trim(); + + const entry = styles.find(s => s.key === cls)!; + if (!entry) { + console.error(e); + throw new Error("Could not find entry for CSS"); + } + + const { location: loc } = entry; + const errLoc: Partial = { + file: loc.filename, + line: loc.start.line, + column: loc.start.column, + length: loc.end.column - loc.start.column, + lineText: loc.text, + }; + + const doesNotExist = e.reason.match(/The `(.+)` class does not exist/)?.[1]; + if (doesNotExist) { + const index = loc.text.indexOf(doesNotExist, loc.start.column); + if (index !== -1) { + errLoc.column = index; + errLoc.length = doesNotExist.length; + } + } + + return { + errors: [ + { + text: e.reason, + location: errLoc, + }, + ], + }; +} diff --git a/src/index.ts b/src/index.ts index 09987e1..f67b2bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import hash from "@emotion/hash"; import type { Config } from "tailwindcss"; import type { SetRequired } from "type-fest"; import type postcss from "postcss"; -import { memoize } from "lodash"; +import { memoize, without } from "lodash"; import { type ClassNameCollector, babelTailwind } from "./babel-tailwind"; import { esbuildPlugin } from "./esbuild-postcss"; import { vitePlugin } from "./vite-plugin"; @@ -130,7 +130,12 @@ export function getTailwindPlugins(options: TailwindPluginOptions) { const buildStyleFile: BuildStyleFile = async path => { const styles = styleMap.get(path)!; const compiled = await compile( - styles.map(({ className, key }) => `.${key} {\n @apply ${className}\n}`).join("\n") + styles + .map( + ({ classNames, key }) => + `.${key} {\n @apply ${without(classNames, "group").join(" ")}\n}` + ) + .join("\n") ); if (path.endsWith(".css")) { return ["css", compiled] as const; diff --git a/src/macro.d.ts b/src/macro.d.ts index 95a657e..901893f 100644 --- a/src/macro.d.ts +++ b/src/macro.d.ts @@ -4,23 +4,77 @@ interface RecursiveStringObject { type CSSAttributeValue = string | (string | RecursiveStringObject)[]; +type Modifier = + | "2xl" + | "active" + | "after" + | "backdrop" + | "before" + | "contrastMore" + | "dark" + | "default" + | "disabled" + | "even" + | "file" + | "first" + | "firstLetter" + | "firstLine" + | "firstOfType" + | "focus" + | "focusVisible" + | "focusWithin" + | "forcedColors" + | "hover" + | "indeterminate" + | "invalid" + | "last" + | "lastOfType" + | "ltr" + | "marker" + | "max2xl" + | "maxLg" + | "maxMd" + | "maxSm" + | "maxXl" + | "md" + | "motionReduce" + | "motionSafe" + | "odd" + | "only" + | "open" + | "placeholder" + | "prefersContrast" + | "print" + | "readOnly" + | "required" + | "rtl" + | "selection" + | "sm" + | "target" + | "visited" + | "xl"; + /** * Tagged template macro function combining Tailwind classes * @example "tw" => tw`p-2 text-center` */ -export interface TailwindFunction { +export type TailwindFunction = { (strings: TemplateStringsArray): string; (...args: (string | RecursiveStringObject)[]): string; -} +} & { + [key in Modifier]: TailwindFunction; +}; /** * Tagged template macro function compiling Tailwind styles * @example "tws" => tws`p-2 text-center` // { padding: 2, textAlign: "center" } */ -export interface TailwindStyleFunction { +export type TailwindStyleFunction = { (strings: TemplateStringsArray): TailwindStyleFunctionReturn; (...args: (string | RecursiveStringObject)[]): TailwindStyleFunctionReturn; -} +} & { + [key in Modifier]: TailwindStyleFunction; +}; export const tw: TailwindFunction; export const tws: TailwindStyleFunction; diff --git a/src/shared.ts b/src/shared.ts index 6385ea3..5378913 100644 --- a/src/shared.ts +++ b/src/shared.ts @@ -19,7 +19,7 @@ export interface SourceLocation { export interface StyleMapEntry { key: string; - className: string; + classNames: string[]; location: SourceLocation; } @@ -47,15 +47,3 @@ export function createPostCSS({ } export type Compile = ReturnType; - -export function toCSSText(tailwindMap: StyleMapEntry[]) { - return tailwindMap - .map(({ className, key }) => `.${key} {\n @apply ${className}\n}`) - .join("\n"); -} - -export function toJSText(tailwindMap: StyleMapEntry[]) { - return tailwindMap - .map(({ className, key }) => `"${key}": "${className}"`) - .join(",\n "); -}