feat(cli): add support for custom Tailwind prefix transformer (#770)

* feat(cli): add support for custom Tailwind prefix

* fix(cli): add tw prefix on classes applied in the css file

* feat(cli): add support for custom tailwind prefix

* chore: add changeset

* style(shadcn-ui): code format

---------

Co-authored-by: shadcn <m@shadcn.com>
This commit is contained in:
Bereket Engida
2023-12-22 00:42:40 +03:00
committed by GitHub
parent 1cf5fad881
commit 4fb98d520f
11 changed files with 477 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn-ui": minor
---
add support for custom tailwind prefix

View File

@@ -27,6 +27,8 @@ import ora from "ora"
import prompts from "prompts"
import * as z from "zod"
import { applyPrefixesCss } from "../utils/transformers/transform-tw-prefix"
const PROJECT_DEPENDENCIES = [
"tailwindcss-animate",
"class-variance-authority",
@@ -132,6 +134,14 @@ export async function promptForConfig(
active: "yes",
inactive: "no",
},
{
type: "text",
name: "tailwindPrefix",
message: `Are you using a custom ${highlight(
"tailwind prefix eg. tw-"
)}? (Leave blank if not)`,
initial: "",
},
{
type: "text",
name: "tailwindConfig",
@@ -168,6 +178,7 @@ export async function promptForConfig(
css: options.tailwindCss,
baseColor: options.tailwindBaseColor,
cssVariables: options.tailwindCssVariables,
prefix: options.tailwindPrefix,
},
rsc: options.rsc,
tsx: options.typescript,
@@ -246,7 +257,10 @@ export async function runInit(cwd: string, config: Config) {
// Write tailwind config.
await fs.writeFile(
config.resolvedPaths.tailwindConfig,
template(tailwindConfigTemplate)({ extension }),
template(tailwindConfigTemplate)({
extension,
prefix: config.tailwind.prefix,
}),
"utf8"
)
@@ -256,7 +270,9 @@ export async function runInit(cwd: string, config: Config) {
await fs.writeFile(
config.resolvedPaths.tailwindCss,
config.tailwind.cssVariables
? baseColor.cssVarsTemplate
? config.tailwind.prefix
? applyPrefixesCss(baseColor.cssVarsTemplate, config.tailwind.prefix)
: baseColor.cssVarsTemplate
: baseColor.inlineColorsTemplate,
"utf8"
)

View File

@@ -28,6 +28,7 @@ export const rawConfigSchema = z
css: z.string(),
baseColor: z.string(),
cssVariables: z.boolean().default(true),
prefix: z.string().default("").optional(),
}),
aliases: z.object({
components: z.string(),

View File

@@ -23,6 +23,7 @@ module.exports = {
'./app/**/*.{<%- extension %>,<%- extension %>x}',
'./src/**/*.{<%- extension %>,<%- extension %>x}',
],
prefix: "<%- prefix %>",
theme: {
container: {
center: true,
@@ -60,6 +61,7 @@ module.exports = {
'./app/**/*.{<%- extension %>,<%- extension %>x}',
'./src/**/*.{<%- extension %>,<%- extension %>x}',
],
prefix: "<%- prefix %>",
theme: {
container: {
center: true,
@@ -138,6 +140,7 @@ const config = {
'./app/**/*.{<%- extension %>,<%- extension %>x}',
'./src/**/*.{<%- extension %>,<%- extension %>x}',
],
prefix: "<%- prefix %>",
theme: {
container: {
center: true,
@@ -178,6 +181,7 @@ const config = {
'./app/**/*.{<%- extension %>,<%- extension %>x}',
'./src/**/*.{<%- extension %>,<%- extension %>x}',
],
prefix: "<%- prefix %>",
theme: {
container: {
center: true,

View File

@@ -10,6 +10,8 @@ import { transformRsc } from "@/src/utils/transformers/transform-rsc"
import { Project, ScriptKind, type SourceFile } from "ts-morph"
import * as z from "zod"
import { transformTwPrefixes } from "./transform-tw-prefix"
export type TransformOpts = {
filename: string
raw: string
@@ -27,6 +29,7 @@ const transformers: Transformer[] = [
transformImport,
transformRsc,
transformCssVars,
transformTwPrefixes,
]
const project = new Project({

View File

@@ -0,0 +1,201 @@
import { Transformer } from "@/src/utils/transformers"
import { SyntaxKind } from "ts-morph"
import { splitClassName } from "./transform-css-vars"
export const transformTwPrefixes: Transformer = async ({
sourceFile,
config,
}) => {
if (!config.tailwind?.prefix) {
return sourceFile
}
// Find the cva function calls.
sourceFile
.getDescendantsOfKind(SyntaxKind.CallExpression)
.filter((node) => node.getExpression().getText() === "cva")
.forEach((node) => {
// cva(base, ...)
if (node.getArguments()[0]?.isKind(SyntaxKind.StringLiteral)) {
const defaultClassNames = node.getArguments()[0]
if (defaultClassNames) {
defaultClassNames.replaceWithText(
`"${applyPrefix(
defaultClassNames.getText()?.replace(/"/g, ""),
config.tailwind.prefix
)}"`
)
}
}
// cva(..., { variants: { ... } })
if (node.getArguments()[1]?.isKind(SyntaxKind.ObjectLiteralExpression)) {
node
.getArguments()[1]
?.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
.find((node) => node.getName() === "variants")
?.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
.forEach((node) => {
node
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
.forEach((node) => {
const classNames = node.getInitializerIfKind(
SyntaxKind.StringLiteral
)
if (classNames) {
classNames?.replaceWithText(
`"${applyPrefix(
classNames.getText()?.replace(/"/g, ""),
config.tailwind.prefix
)}"`
)
}
})
})
}
})
// Find all jsx attributes with the name className.
sourceFile.getDescendantsOfKind(SyntaxKind.JsxAttribute).forEach((node) => {
if (node.getName() === "className") {
// className="..."
if (node.getInitializer()?.isKind(SyntaxKind.StringLiteral)) {
const value = node.getInitializer()
if (value) {
value.replaceWithText(
`"${applyPrefix(
value.getText()?.replace(/"/g, ""),
config.tailwind.prefix
)}"`
)
}
}
// className={...}
if (node.getInitializer()?.isKind(SyntaxKind.JsxExpression)) {
// Check if it's a call to cn().
const callExpression = node
.getInitializer()
?.getDescendantsOfKind(SyntaxKind.CallExpression)
.find((node) => node.getExpression().getText() === "cn")
if (callExpression) {
// Loop through the arguments.
callExpression.getArguments().forEach((node) => {
if (
node.isKind(SyntaxKind.ConditionalExpression) ||
node.isKind(SyntaxKind.BinaryExpression)
) {
node
.getChildrenOfKind(SyntaxKind.StringLiteral)
.forEach((node) => {
node.replaceWithText(
`"${applyPrefix(
node.getText()?.replace(/"/g, ""),
config.tailwind.prefix
)}"`
)
})
}
if (node.isKind(SyntaxKind.StringLiteral)) {
node.replaceWithText(
`"${applyPrefix(
node.getText()?.replace(/"/g, ""),
config.tailwind.prefix
)}"`
)
}
})
}
}
}
// classNames={...}
if (node.getName() === "classNames") {
if (node.getInitializer()?.isKind(SyntaxKind.JsxExpression)) {
node
.getDescendantsOfKind(SyntaxKind.PropertyAssignment)
.forEach((node) => {
if (node.getInitializer()?.isKind(SyntaxKind.CallExpression)) {
const callExpression = node.getInitializerIfKind(
SyntaxKind.CallExpression
)
if (callExpression) {
// Loop through the arguments.
callExpression.getArguments().forEach((arg) => {
if (arg.isKind(SyntaxKind.ConditionalExpression)) {
arg
.getChildrenOfKind(SyntaxKind.StringLiteral)
.forEach((node) => {
node.replaceWithText(
`"${applyPrefix(
node.getText()?.replace(/"/g, ""),
config.tailwind.prefix
)}"`
)
})
}
if (arg.isKind(SyntaxKind.StringLiteral)) {
arg.replaceWithText(
`"${applyPrefix(
arg.getText()?.replace(/"/g, ""),
config.tailwind.prefix
)}"`
)
}
})
}
}
if (node.getInitializer()?.isKind(SyntaxKind.StringLiteral)) {
if (node.getName() !== "variant") {
const classNames = node.getInitializer()
if (classNames) {
classNames.replaceWithText(
`"${applyPrefix(
classNames.getText()?.replace(/"/g, ""),
config.tailwind.prefix
)}"`
)
}
}
}
})
}
}
})
return sourceFile
}
export function applyPrefix(input: string, prefix: string = "") {
const classNames = input.split(" ")
const prefixed: string[] = []
for (let className of classNames) {
const [variant, value, modifier] = splitClassName(className)
if (variant) {
modifier
? prefixed.push(`${variant}:${prefix}${value}/${modifier}`)
: prefixed.push(`${variant}:${prefix}${value}`)
} else {
modifier
? prefixed.push(`${prefix}${value}/${modifier}`)
: prefixed.push(`${prefix}${value}`)
}
}
return prefixed.join(" ")
}
export function applyPrefixesCss(css: string, prefix: string) {
const lines = css.split("\n")
for (let line of lines) {
if (line.includes("@apply")) {
const originalTWCls = line.replace("@apply", "").trim()
const prefixedTwCls = applyPrefix(originalTWCls, prefix)
css = css.replace(originalTWCls, prefixedTwCls)
}
}
return css
}

View File

@@ -4,7 +4,8 @@
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true
"cssVariables": true,
"prefix": "tw-"
},
"rsc": false,
"aliases": {

View File

@@ -0,0 +1,118 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`transform tailwind prefix 1`] = `
"import * as React from \\"react\\"
export function Foo() {
return (
<div
className=\\"tw-bg-background hover:tw-bg-muted tw-text-primary-foreground sm:focus:tw-text-accent-foreground\\">foo</div>
);
}
"
`;
exports[`transform tailwind prefix 2`] = `
"import * as React from \\"react\\"
export function Foo() {
return (
<div
className=\\"tw-bg-white hover:tw-bg-stone-100 tw-text-stone-50 sm:focus:tw-text-stone-900 dark:tw-bg-stone-950 dark:hover:tw-bg-stone-800 dark:tw-text-stone-900 dark:sm:focus:tw-text-stone-50\\">foo</div>
);
}
"
`;
exports[`transform tailwind prefix 3`] = `
"import * as React from \\"react\\"
export function Foo() {
return (
<div
className={cn(
\\"tw-bg-white hover:tw-bg-stone-100 dark:tw-bg-stone-950 dark:hover:tw-bg-stone-800\\",
true && \\"tw-text-stone-50 sm:focus:tw-text-stone-900 dark:tw-text-stone-900 dark:sm:focus:tw-text-stone-50\\"
)}>foo</div>
);
}
"
`;
exports[`transform tailwind prefix 4`] = `
"@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--ring: 217.9 10.6% 64.9%;
--radius: 0.5rem;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%;
--ring: 215 27.9% 16.9%;
}
}
@layer base {
* {
@apply tw-border-border;
}
body {
@apply tw-bg-background tw-text-foreground;
}
}"
`;

View File

@@ -0,0 +1,42 @@
import { describe, expect, test } from "vitest"
import { applyPrefix } from "../../src/utils/transformers/transform-tw-prefix"
describe("apply tailwind prefix", () => {
test.each([
{
input: "bg-slate-800 text-gray-500",
output: "tw-bg-slate-800 tw-text-gray-500",
},
{
input: "hover:dark:bg-background dark:text-foreground",
output: "hover:dark:tw-bg-background dark:tw-text-foreground",
},
{
input:
"rounded-lg border border-slate-200 bg-white text-slate-950 shadow-sm dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50",
output:
"tw-rounded-lg tw-border tw-border-slate-200 tw-bg-white tw-text-slate-950 tw-shadow-sm dark:tw-border-slate-800 dark:tw-bg-slate-950 dark:tw-text-slate-50",
},
{
input:
"text-red-500 border-red-500/50 dark:border-red-500 [&>svg]:text-red-500 text-red-500 dark:text-red-900 dark:border-red-900/50 dark:dark:border-red-900 dark:[&>svg]:text-red-900 dark:text-red-900",
output:
"tw-text-red-500 tw-border-red-500/50 dark:tw-border-red-500 [&>svg]:tw-text-red-500 tw-text-red-500 dark:tw-text-red-900 dark:tw-border-red-900/50 dark:dark:tw-border-red-900 dark:[&>svg]:tw-text-red-900 dark:tw-text-red-900",
},
{
input:
"flex h-full w-full items-center justify-center rounded-full bg-muted",
output:
"tw-flex tw-h-full tw-w-full tw-items-center tw-justify-center tw-rounded-full tw-bg-muted",
},
{
input:
"absolute right-4 top-4 bg-primary rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary",
output:
"tw-absolute tw-right-4 tw-top-4 tw-bg-primary tw-rounded-sm tw-opacity-70 tw-ring-offset-background tw-transition-opacity hover:tw-opacity-100 focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-ring focus:tw-ring-offset-2 disabled:tw-pointer-events-none data-[state=open]:tw-bg-secondary",
},
])(`applyTwPrefix($input) -> $output`, ({ input, output }) => {
expect(applyPrefix(input, "tw-")).toBe(output)
})
})

View File

@@ -91,6 +91,7 @@ test("get config", async () => {
baseColor: "zinc",
css: "src/app/globals.css",
cssVariables: true,
prefix: "tw-"
},
aliases: {
components: "~/components",

View File

@@ -0,0 +1,82 @@
import { expect, test } from "vitest"
import { transform } from "../../src/utils/transformers"
import { applyPrefixesCss } from "../../src/utils/transformers/transform-tw-prefix"
import stone from "../fixtures/colors/stone.json"
test("transform tailwind prefix", async () => {
expect(
await transform({
filename: "test.ts",
raw: `import * as React from "react"
export function Foo() {
return <div className="bg-background hover:bg-muted text-primary-foreground sm:focus:text-accent-foreground">foo</div>
}
`,
config: {
tailwind: {
baseColor: "stone",
prefix: "tw-",
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
},
baseColor: "stone",
})
).toMatchSnapshot()
expect(
await transform({
filename: "test.ts",
raw: `import * as React from "react"
export function Foo() {
return <div className="bg-background hover:bg-muted text-primary-foreground sm:focus:text-accent-foreground">foo</div>
}
`,
config: {
tailwind: {
baseColor: "stone",
cssVariables: false,
prefix: "tw-",
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
},
baseColor: stone,
})
).toMatchSnapshot()
expect(
await transform({
filename: "test.ts",
raw: `import * as React from "react"
export function Foo() {
return <div className={cn("bg-background hover:bg-muted", true && "text-primary-foreground sm:focus:text-accent-foreground")}>foo</div>
}
`,
config: {
tailwind: {
baseColor: "stone",
cssVariables: false,
prefix: "tw-",
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
},
baseColor: stone,
})
).toMatchSnapshot()
expect(
applyPrefixesCss(
"@tailwind base;\n@tailwind components;\n@tailwind utilities;\n \n@layer base {\n :root {\n --background: 0 0% 100%;\n --foreground: 224 71.4% 4.1%;\n \n --muted: 220 14.3% 95.9%;\n --muted-foreground: 220 8.9% 46.1%;\n \n --popover: 0 0% 100%;\n --popover-foreground: 224 71.4% 4.1%;\n \n --card: 0 0% 100%;\n --card-foreground: 224 71.4% 4.1%;\n \n --border: 220 13% 91%;\n --input: 220 13% 91%;\n \n --primary: 220.9 39.3% 11%;\n --primary-foreground: 210 20% 98%;\n \n --secondary: 220 14.3% 95.9%;\n --secondary-foreground: 220.9 39.3% 11%;\n \n --accent: 220 14.3% 95.9%;\n --accent-foreground: 220.9 39.3% 11%;\n \n --destructive: 0 84.2% 60.2%;\n --destructive-foreground: 210 20% 98%;\n \n --ring: 217.9 10.6% 64.9%;\n \n --radius: 0.5rem;\n }\n \n .dark {\n --background: 224 71.4% 4.1%;\n --foreground: 210 20% 98%;\n \n --muted: 215 27.9% 16.9%;\n --muted-foreground: 217.9 10.6% 64.9%;\n \n --popover: 224 71.4% 4.1%;\n --popover-foreground: 210 20% 98%;\n \n --card: 224 71.4% 4.1%;\n --card-foreground: 210 20% 98%;\n \n --border: 215 27.9% 16.9%;\n --input: 215 27.9% 16.9%;\n \n --primary: 210 20% 98%;\n --primary-foreground: 220.9 39.3% 11%;\n \n --secondary: 215 27.9% 16.9%;\n --secondary-foreground: 210 20% 98%;\n \n --accent: 215 27.9% 16.9%;\n --accent-foreground: 210 20% 98%;\n \n --destructive: 0 62.8% 30.6%;\n --destructive-foreground: 0 85.7% 97.3%;\n \n --ring: 215 27.9% 16.9%;\n }\n}\n \n@layer base {\n * {\n @apply border-border;\n }\n body {\n @apply bg-background text-foreground;\n }\n}",
"tw-"
)
).toMatchSnapshot()
})