Files
babel-tailwind/src/index.ts
2025-02-09 21:37:27 -05:00

222 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createRequire } from "node:module";
import hash from "@emotion/hash";
import type { BabelOptions, Options as ReactOptions } from "@vitejs/plugin-react";
import { transformSync } from "esbuild";
import { memoize, without } from "lodash-es";
import type { Config } from "tailwindcss";
import type { SetRequired } from "type-fest";
import { type ClassNameCollector, babelTailwind } from "./babel/index";
import { toJSCode } from "./css-to-js";
import { esbuildPlugin } from "./esbuild-postcss";
import { type StyleMap, createPostCSS } from "./shared";
import { vitePlugin } from "./vite-plugin";
export { isMacrosName } from "./vite-plugin";
export { babelPlugin } from "./esbuild-babel";
export { createPostCSS } from "./shared";
type GetClassName = (className: string) => string;
export type BuildStyleFile = (
path: string
) => Promise<readonly ["css" | "local-css", string] | readonly ["js", string]>;
const require = createRequire(import.meta.url);
export interface TailwindPluginOptions {
/**
* Tailwind CSS configuration
*/
tailwindConfig?: Omit<Config, "content">;
/**
* Prefix to all Tailwind stylesheets
*
* @example
* tailwind base;
* tailwind components;
* tailwind utilities;
*/
prefix?: string;
/**
* Attribute to use for tailwind classes in JSX
* @default "css"
*/
jsxAttributeName?: string;
/**
* What to do with the original attribute after processing
* @default "delete"
*/
jsxAttributeAction?: "delete" | "preserve" | ["rename", 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";
/**
* Use react-aria-components `composeRenderProps` function.
*/
composeRenderProps?: boolean;
/**
* @internal
*/
styleMap?: StyleMap;
/**
* Custom CSS compile function
* @example
* async css => (await postcss.process(css, { plugins: [tailwindcss()] })).css
*/
compile?(css: string): Promise<string>;
/**
* Using the vite plugin?
*/
vite?: boolean;
/**
* Keep the original classnames in the CSS output
*/
addSourceAsComment?: boolean;
/**
* Emit as CSS modules
*/
cssModules?: boolean;
/**
* Emit type. `css-import` for plain CSS import,
* `css-module` for CSS modules, `css-in-js` for JS.
*/
// emitType: "css-import" | "css-module" | "css-in-js";
}
export type ResolveTailwindOptions = SetRequired<
TailwindPluginOptions,
"clsx" | "jsxAttributeAction" | "jsxAttributeName" | "styleMap" | "tailwindConfig"
>;
/**
* 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);
/**
* Main entry. Returns the plugins and utilities for processing Tailwind
* classNames in JS.
*
* @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 { addSourceAsComment, compile: _compile, cssModules } = options;
const resolvedOptions: ResolveTailwindOptions = {
getClassName,
jsxAttributeAction: "delete",
jsxAttributeName: "css",
styleMap: new Map(),
tailwindConfig: {},
...options,
};
const getCompiler = () => createPostCSS(resolvedOptions);
const { styleMap } = resolvedOptions;
const compile = _compile ?? memoize(getCompiler());
const buildStyleFile: BuildStyleFile = async path => {
const styles = styleMap.get(path)!;
const compiled = await compile(
styles
.map(({ classNames, key }) => {
const tw = without(classNames, "group").join(" ");
return [
`.${key} {`,
addSourceAsComment && ` /* @preserve ${tw} */`,
` @apply ${tw};`,
"}",
]
.filter(Boolean)
.join("\n");
})
.join("\n")
);
if (path.endsWith(".css")) {
return [
cssModules ? "local-css" : "css",
transformSync(compiled, { loader: "css" }).code,
] as const;
} else if (path.endsWith(".js")) {
const js = toJSCode(compiled, x => x.slice(1));
return ["js", js] as const;
} else {
throw new Error("Unknown file extension");
}
};
const babel = (onCollect?: ClassNameCollector) =>
babelTailwind(resolvedOptions, onCollect);
const patchBabelOptions = (options: BabelOptions) => {
(options.plugins ??= []).push(babel());
};
return {
compile,
babel: (onCollect?: ClassNameCollector) => babelTailwind(resolvedOptions, onCollect),
esbuild: () => esbuildPlugin({ styleMap, compile, buildStyleFile }),
vite: () => {
resolvedOptions.vite = true;
return vitePlugin({ styleMap, compile, buildStyleFile });
},
react(options: ReactOptions = {}) {
const reactModule = require("@vitejs/plugin-react");
const reactPlugin: typeof import("@vitejs/plugin-react").default =
"default" in reactModule ? reactModule.default : reactModule;
options.babel ??= {};
if (typeof options.babel === "function") {
const fn = options.babel;
options.babel = (id, options) => {
const result = fn(id, options);
patchBabelOptions(result);
return result;
};
} else {
patchBabelOptions(options.babel);
}
return reactPlugin(options);
},
styleMap,
options,
getCompiler,
buildStyleFile,
[Symbol.dispose]() {
styleMap.clear();
},
};
}