Initial commit
This commit is contained in:
318
src/index.ts
Normal file
318
src/index.ts
Normal file
@ -0,0 +1,318 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename, dirname, extname, join } from "node:path";
|
||||
import { once } from "lodash";
|
||||
import hash from "@emotion/hash";
|
||||
import type babel from "@babel/core";
|
||||
import { type NodePath, type types as t, transform } from "@babel/core";
|
||||
import { type Plugin } from "esbuild";
|
||||
import tailwind, { type Config } from "tailwindcss";
|
||||
import postcss from "postcss";
|
||||
|
||||
const definePlugin =
|
||||
<T>(fn: (runtime: typeof babel) => babel.Visitor<babel.PluginPass & T>) =>
|
||||
(runtime: typeof babel) => {
|
||||
const plugin: babel.PluginObj<babel.PluginPass & T> = {
|
||||
visitor: fn(runtime),
|
||||
};
|
||||
return plugin as babel.PluginObj;
|
||||
};
|
||||
|
||||
const extractJSXContainer = (attr: NonNullable<t.JSXAttribute["value"]>): t.Expression =>
|
||||
attr.type === "JSXExpressionContainer" ? (attr.expression as t.Expression) : attr;
|
||||
|
||||
function matchPath(
|
||||
nodePath: NodePath<t.Node | null | undefined>,
|
||||
fns: (dig: (nodePath: NodePath) => void) => babel.Visitor
|
||||
) {
|
||||
if (!nodePath.node) return;
|
||||
const fn = fns(path => matchPath(path, fns))[nodePath.node.type] as any;
|
||||
fn?.(nodePath);
|
||||
}
|
||||
|
||||
const ESBUILD_NAMESPACE = "babel-tailwind";
|
||||
|
||||
export interface TailwindPluginOptions {
|
||||
/**
|
||||
* Tailwind CSS configuration
|
||||
*/
|
||||
tailwindConfig: Omit<Config, "content">;
|
||||
|
||||
/**
|
||||
* Additional PostCSS plugins (optional)
|
||||
*/
|
||||
postCSSPlugins?: postcss.AcceptedPlugin[];
|
||||
|
||||
/**
|
||||
* Attribute to use for tailwind classes in JSX
|
||||
* @default "css"
|
||||
*/
|
||||
jsxAttributeName?: string;
|
||||
|
||||
/**
|
||||
* The prefix to use for the generated class names.
|
||||
* @default className => `tw-${hash(className)}`
|
||||
*/
|
||||
getClassName?: GetClassName;
|
||||
|
||||
/**
|
||||
* Preferred library for classnames
|
||||
*/
|
||||
clsx: "clsx" | "classnames" | "emotion";
|
||||
}
|
||||
|
||||
type GetClassName = (className: string) => string;
|
||||
|
||||
/**
|
||||
* Hashes and prefixes a string of Tailwind class names.
|
||||
* @example getClassName("p-2 text-center") // "tw-1r6fxxz"
|
||||
*/
|
||||
export const getClassName: GetClassName = cls => "tw-" + hash(cls);
|
||||
|
||||
const babelTailwind = (
|
||||
styleMap: Map<string, string>,
|
||||
{
|
||||
clsx,
|
||||
getClassName: getClass = getClassName,
|
||||
jsxAttributeName = "css",
|
||||
}: TailwindPluginOptions
|
||||
) =>
|
||||
definePlugin<{
|
||||
getCx: () => t.Identifier;
|
||||
tailwindMap: Map<string, string>;
|
||||
}>(({ types: t }) => ({
|
||||
Program: {
|
||||
enter(path, state) {
|
||||
let cx: t.Identifier;
|
||||
|
||||
state.tailwindMap = new Map();
|
||||
|
||||
function getClsxImport() {
|
||||
switch (clsx) {
|
||||
case "emotion":
|
||||
return t.importDeclaration(
|
||||
[t.importSpecifier(cx, t.identifier("cx"))],
|
||||
t.stringLiteral("@emotion/css")
|
||||
);
|
||||
case "clsx":
|
||||
return t.importDeclaration(
|
||||
[t.importDefaultSpecifier(cx)],
|
||||
t.stringLiteral("clsx")
|
||||
);
|
||||
case "classnames":
|
||||
return t.importDeclaration(
|
||||
[t.importDefaultSpecifier(cx)],
|
||||
t.stringLiteral("classnames")
|
||||
);
|
||||
default:
|
||||
throw new Error("Unknown clsx library");
|
||||
}
|
||||
}
|
||||
|
||||
state.getCx = () => {
|
||||
if (cx == null) {
|
||||
cx = path.scope.generateUidIdentifier("cx");
|
||||
path.node.body.unshift(getClsxImport());
|
||||
}
|
||||
return t.cloneNode(cx);
|
||||
};
|
||||
},
|
||||
|
||||
exit({ node }, { filename, tailwindMap }) {
|
||||
if (!tailwindMap.size) return;
|
||||
if (!filename) {
|
||||
throw new Error("babel: missing state.filename");
|
||||
}
|
||||
|
||||
const cssName = basename(filename, extname(filename)) + ".css";
|
||||
node.body.unshift(
|
||||
t.importDeclaration([], t.stringLiteral(`tailwind:./${cssName}`))
|
||||
);
|
||||
|
||||
const result = Array.from(tailwindMap)
|
||||
.map(([className, value]) => `.${className} {\n @apply ${value}\n}`)
|
||||
.join("\n");
|
||||
styleMap.set(join(dirname(filename), cssName), result);
|
||||
},
|
||||
},
|
||||
|
||||
TaggedTemplateExpression(path, { tailwindMap }) {
|
||||
const {
|
||||
tag,
|
||||
quasi: { quasis, expressions },
|
||||
} = path.node;
|
||||
if (!t.isIdentifier(tag, { name: "tw" })) return;
|
||||
|
||||
if (expressions.length) {
|
||||
throw new Error("tw`` should not contain expressions");
|
||||
}
|
||||
|
||||
const value = quasis[0].value.cooked;
|
||||
if (value) {
|
||||
const trimmed = value.replace(/\s+/g, " ").trim();
|
||||
const className = getClass(trimmed);
|
||||
tailwindMap.set(className, trimmed);
|
||||
path.replaceWith(t.stringLiteral(className));
|
||||
}
|
||||
},
|
||||
|
||||
JSXAttribute(path, { tailwindMap, getCx }) {
|
||||
const { name } = path.node;
|
||||
const valuePath = path.get("value");
|
||||
if (name.name !== jsxAttributeName || !valuePath.node) return;
|
||||
|
||||
const parent = path.parent as t.JSXOpeningElement;
|
||||
const classNameAttribute = parent.attributes.find(
|
||||
(attr): attr is t.JSXAttribute =>
|
||||
t.isJSXAttribute(attr) && attr.name.name === "className"
|
||||
);
|
||||
|
||||
matchPath(valuePath, go => ({
|
||||
StringLiteral(path) {
|
||||
const { value } = path.node;
|
||||
if (value) {
|
||||
const trimmed = value.replace(/\s+/g, " ").trim();
|
||||
const className = getClass(trimmed);
|
||||
tailwindMap.set(className, trimmed);
|
||||
path.replaceWith(t.stringLiteral(className));
|
||||
}
|
||||
},
|
||||
JSXExpressionContainer(path) {
|
||||
go(path.get("expression"));
|
||||
},
|
||||
ConditionalExpression(path) {
|
||||
go(path.get("consequent"));
|
||||
go(path.get("alternate"));
|
||||
},
|
||||
LogicalExpression(path) {
|
||||
go(path.get("right"));
|
||||
},
|
||||
CallExpression(path) {
|
||||
for (const arg of path.get("arguments")) {
|
||||
go(arg);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
if (classNameAttribute) {
|
||||
const attrValue = classNameAttribute.value!;
|
||||
|
||||
// If both are string literals, we can merge them directly here
|
||||
if (t.isStringLiteral(attrValue) && t.isStringLiteral(valuePath.node)) {
|
||||
attrValue.value +=
|
||||
(attrValue.value.at(-1) === " " ? "" : " ") + valuePath.node.value;
|
||||
} else {
|
||||
classNameAttribute.value = t.jsxExpressionContainer(
|
||||
t.callExpression(getCx(), [
|
||||
extractJSXContainer(attrValue),
|
||||
extractJSXContainer(valuePath.node),
|
||||
])
|
||||
);
|
||||
}
|
||||
} else {
|
||||
parent.attributes.push(
|
||||
t.jsxAttribute(t.jsxIdentifier("className"), valuePath.node)
|
||||
);
|
||||
}
|
||||
path.remove();
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* An esbuild plugin that processes files with Babel if `getPlugins` returns any plugins.
|
||||
*/
|
||||
export const babelPlugin = ({
|
||||
filter = /\.[jt]sx?$/,
|
||||
getPlugins,
|
||||
}: {
|
||||
filter?: RegExp;
|
||||
getPlugins(file: { path: string; contents: string }): babel.PluginItem[];
|
||||
}): Plugin => ({
|
||||
name: "babel-plugin",
|
||||
setup(build) {
|
||||
build.onLoad({ filter }, ({ path }) => {
|
||||
const load = once(() => readFileSync(path, "utf-8"));
|
||||
const plugins = getPlugins({
|
||||
path,
|
||||
get contents() {
|
||||
return load();
|
||||
},
|
||||
});
|
||||
|
||||
if (!plugins.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { code } = transform(load(), {
|
||||
parserOpts: {
|
||||
plugins: ["jsx", "typescript"],
|
||||
},
|
||||
filename: path,
|
||||
plugins,
|
||||
})!;
|
||||
|
||||
return {
|
||||
contents: code!,
|
||||
loader: extname(path).slice(1) as "js" | "ts",
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const tailwindPlugin = (
|
||||
styleMap: Map<string, string>,
|
||||
{ tailwindConfig, postCSSPlugins = [] }: TailwindPluginOptions
|
||||
): Plugin => ({
|
||||
name: "tailwind",
|
||||
|
||||
setup(build) {
|
||||
const post = postcss([
|
||||
tailwind({
|
||||
...tailwindConfig,
|
||||
content: [{ raw: "<br>", extension: "html" }],
|
||||
}),
|
||||
...postCSSPlugins,
|
||||
]);
|
||||
|
||||
build.onResolve({ filter: /^tailwind:.+\.css$/ }, ({ path, importer }) => {
|
||||
const resolved = join(dirname(importer), path.replace(/^tailwind:/, ""));
|
||||
if (styleMap.has(resolved)) {
|
||||
return {
|
||||
path: resolved,
|
||||
namespace: ESBUILD_NAMESPACE,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
build.onLoad({ filter: /.*/, namespace: ESBUILD_NAMESPACE }, async ({ path }) => {
|
||||
if (!styleMap.has(path)) return;
|
||||
const result = await post.process(styleMap.get(path)!, { from: undefined });
|
||||
|
||||
return {
|
||||
contents: result.css,
|
||||
loader: "css",
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Main entry. Returns the esbuild and babel plugins for tailwind.
|
||||
* @example
|
||||
* import { build } from "esbuild";
|
||||
* import getTailwindPlugins, { babelPlugin } from "babel-tailwind";
|
||||
*
|
||||
* const tailwind = getTailwindPlugins(options);
|
||||
* const result = await build({
|
||||
* plugins: [
|
||||
* babelPlugin({ getPlugins: () => [tailwind.babel] }),
|
||||
* tailwind.esbuild,
|
||||
* ],
|
||||
* });
|
||||
*/
|
||||
export function getTailwindPlugins(options: TailwindPluginOptions) {
|
||||
const styleMap = new Map<string, string>();
|
||||
return {
|
||||
babel: babelTailwind(styleMap, options),
|
||||
esbuild: tailwindPlugin(styleMap, options),
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user