diff --git a/.changeset/plenty-mugs-applaud.md b/.changeset/plenty-mugs-applaud.md new file mode 100644 index 0000000000..6a8204e599 --- /dev/null +++ b/.changeset/plenty-mugs-applaud.md @@ -0,0 +1,5 @@ +--- +"shadcn-ui": minor +--- + +add support for custom tailwind prefix diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 2653af78c9..1fe39a5b10 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -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" ) diff --git a/packages/cli/src/utils/get-config.ts b/packages/cli/src/utils/get-config.ts index 4ea29431c2..3aac937804 100644 --- a/packages/cli/src/utils/get-config.ts +++ b/packages/cli/src/utils/get-config.ts @@ -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(), diff --git a/packages/cli/src/utils/templates.ts b/packages/cli/src/utils/templates.ts index f110cf90fc..f602416b4d 100644 --- a/packages/cli/src/utils/templates.ts +++ b/packages/cli/src/utils/templates.ts @@ -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, diff --git a/packages/cli/src/utils/transformers/index.ts b/packages/cli/src/utils/transformers/index.ts index b5699d71e6..daa1bb73bc 100644 --- a/packages/cli/src/utils/transformers/index.ts +++ b/packages/cli/src/utils/transformers/index.ts @@ -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({ diff --git a/packages/cli/src/utils/transformers/transform-tw-prefix.ts b/packages/cli/src/utils/transformers/transform-tw-prefix.ts new file mode 100644 index 0000000000..1fed9a883f --- /dev/null +++ b/packages/cli/src/utils/transformers/transform-tw-prefix.ts @@ -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 +} diff --git a/packages/cli/test/fixtures/config-full/components.json b/packages/cli/test/fixtures/config-full/components.json index 3039e5a6e7..675abb22e9 100644 --- a/packages/cli/test/fixtures/config-full/components.json +++ b/packages/cli/test/fixtures/config-full/components.json @@ -4,7 +4,8 @@ "config": "tailwind.config.ts", "css": "src/app/globals.css", "baseColor": "zinc", - "cssVariables": true + "cssVariables": true, + "prefix": "tw-" }, "rsc": false, "aliases": { diff --git a/packages/cli/test/utils/__snapshots__/transform-tw-prefix.test.ts.snap b/packages/cli/test/utils/__snapshots__/transform-tw-prefix.test.ts.snap new file mode 100644 index 0000000000..7667c37104 --- /dev/null +++ b/packages/cli/test/utils/__snapshots__/transform-tw-prefix.test.ts.snap @@ -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 ( +
foo
+ ); + } + " +`; + +exports[`transform tailwind prefix 2`] = ` +"import * as React from \\"react\\" +export function Foo() { + return ( +
foo
+ ); +} + " +`; + +exports[`transform tailwind prefix 3`] = ` +"import * as React from \\"react\\" +export function Foo() { + return ( +
foo
+ ); +} + " +`; + +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; + } +}" +`; diff --git a/packages/cli/test/utils/apply-prefix.test.ts b/packages/cli/test/utils/apply-prefix.test.ts new file mode 100644 index 0000000000..9afed4c4ed --- /dev/null +++ b/packages/cli/test/utils/apply-prefix.test.ts @@ -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) + }) +}) diff --git a/packages/cli/test/utils/get-config.test.ts b/packages/cli/test/utils/get-config.test.ts index 5ab65a3732..bb0092fbef 100644 --- a/packages/cli/test/utils/get-config.test.ts +++ b/packages/cli/test/utils/get-config.test.ts @@ -91,6 +91,7 @@ test("get config", async () => { baseColor: "zinc", css: "src/app/globals.css", cssVariables: true, + prefix: "tw-" }, aliases: { components: "~/components", diff --git a/packages/cli/test/utils/transform-tw-prefix.test.ts b/packages/cli/test/utils/transform-tw-prefix.test.ts new file mode 100644 index 0000000000..5ba1efdc77 --- /dev/null +++ b/packages/cli/test/utils/transform-tw-prefix.test.ts @@ -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
foo
+ } + `, + 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
foo
+} + `, + 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
foo
+} + `, + 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() +})