This commit is contained in:
Alex 2024-07-06 02:49:25 -04:00
parent 2b3812b2ba
commit d4404f7ae2
11 changed files with 267 additions and 119 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -42,8 +42,6 @@ export function getBuild(name: string) {
}) {
const tailwind = getTailwindPlugins({
tailwindConfig: {},
macroFunction: "tw",
macroStyleFunction: "tws",
...options,
});
const result = await esbuild.build({

View File

@ -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<typeof getState>;
vite: bustCache,
} = options;
function getState(path: NodePath<t.Program>, state: babel.PluginPass, t: BabelTypes) {
type BabelPluginUtils = ReturnType<typeof getUtils>;
function getUtils(path: NodePath<t.Program>, 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<BabelPluginState>(({ types: t }) => ({
return definePlugin<BabelPluginUtils>(({ 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<typeof getMacros>[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 =
<T>(fn: (runtime: typeof babel) => babel.Visitor<babel.PluginPass & T>) =>
(runtime: typeof babel) => {
const plugin: babel.PluginObj<babel.PluginPass & T> = {
<T>(fn: (runtime: typeof b) => b.Visitor<b.PluginPass & T>) =>
(runtime: typeof b) => {
const plugin: b.PluginObj<b.PluginPass & T> = {
visitor: fn(runtime),
};
return plugin as babel.PluginObj;
return plugin as b.PluginObj;
};
const extractJSXContainer = (attr: NonNullable<t.JSXAttribute["value"]>): t.Expression =>
@ -398,13 +432,17 @@ const extractJSXContainer = (attr: NonNullable<t.JSXAttribute["value"]>): t.Expr
function matchPath(
nodePath: NodePath<t.Node | null | undefined>,
fns: (dig: (nodePath: NodePath<t.Node | null | undefined>) => void) => babel.Visitor
fns: (dig: (nodePath: NodePath<t.Node | null | undefined>) => 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 = <T extends t.Node>(
nodePath: NodePath<t.Node | null | undefined> | null,
predicate: (node: t.Node) => node is T
@ -413,15 +451,12 @@ const isNodePath = <T extends t.Node>(
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 = <K extends string | number, V, R>(
map: Record<K, V>,

View File

@ -1,5 +1,5 @@
import cx from "clsx";
import { type FunctionComponent, forwardRef } from "react";
import cx from "clsx";
interface WithClassName<Props = object> extends FunctionComponent<Props> {
className: string;
@ -22,12 +22,12 @@ export const classed: {
type: "input",
className: PresetClassName<InputProps>,
defaultProps?: Partial<InputProps>
): InputProps;
): React.FunctionComponent<InputProps>;
<K extends keyof JSX.IntrinsicElements>(
type: K,
className: PresetClassName<JSX.IntrinsicElements[K]>,
defaultProps?: Partial<JSX.IntrinsicElements[K]>
): JSX.IntrinsicElements[K];
): React.FunctionComponent<JSX.IntrinsicElements[K]>;
(
type: string,
className: PresetClassName,

View File

@ -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<esbuild.Location> = {
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<esbuild.Location> = {
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,
},
],
};
}

View File

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

62
src/macro.d.ts vendored
View File

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

View File

@ -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<typeof createPostCSS>;
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 ");
}