Initial commit

This commit is contained in:
Alex
2024-03-29 20:28:56 -04:00
commit 0ed31ebc0f
12 changed files with 4228 additions and 0 deletions

318
src/index.ts Normal file
View 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),
};
}