diff --git a/.changeset/few-houses-impress.md b/.changeset/few-houses-impress.md new file mode 100644 index 00000000000..8feeafd9798 --- /dev/null +++ b/.changeset/few-houses-impress.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +add theme vars support diff --git a/packages/shadcn/src/utils/add-components.ts b/packages/shadcn/src/utils/add-components.ts index 5ed529cf155..8d5a8b6e07d 100644 --- a/packages/shadcn/src/utils/add-components.ts +++ b/packages/shadcn/src/utils/add-components.ts @@ -15,6 +15,7 @@ import { workspaceConfigSchema, type Config, } from "@/src/utils/get-config" +import { getProjectTailwindVersionFromConfig } from "@/src/utils/get-project-info" import { handleError } from "@/src/utils/handle-error" import { logger } from "@/src/utils/logger" import { spinner } from "@/src/utils/spinner" @@ -74,12 +75,16 @@ async function addProjectComponents( } registrySpinner?.succeed() + const tailwindVersion = await getProjectTailwindVersionFromConfig(config) + await updateTailwindConfig(tree.tailwind?.config, config, { silent: options.silent, + tailwindVersion, }) await updateCssVars(tree.cssVars, config, { cleanupDefaultNextStyles: options.isNewProject, silent: options.silent, + tailwindVersion, }) await updateDependencies(tree.dependencies, config, { @@ -143,6 +148,10 @@ async function addWorkspaceComponents( ? workspaceConfig.ui : config + const tailwindVersion = await getProjectTailwindVersionFromConfig( + targetConfig + ) + const workspaceRoot = findCommonRoot( config.resolvedPaths.cwd, targetConfig.resolvedPaths.ui @@ -155,6 +164,7 @@ async function addWorkspaceComponents( if (component.tailwind?.config) { await updateTailwindConfig(component.tailwind?.config, targetConfig, { silent: true, + tailwindVersion, }) filesUpdated.push( path.relative(workspaceRoot, targetConfig.resolvedPaths.tailwindConfig) @@ -165,6 +175,7 @@ async function addWorkspaceComponents( if (component.cssVars) { await updateCssVars(component.cssVars, targetConfig, { silent: true, + tailwindVersion, }) filesUpdated.push( path.relative(workspaceRoot, targetConfig.resolvedPaths.tailwindCss) diff --git a/packages/shadcn/src/utils/get-project-info.ts b/packages/shadcn/src/utils/get-project-info.ts index a258c5ee607..1351118c591 100644 --- a/packages/shadcn/src/utils/get-project-info.ts +++ b/packages/shadcn/src/utils/get-project-info.ts @@ -12,6 +12,8 @@ import fs from "fs-extra" import { loadConfig } from "tsconfig-paths" import { z } from "zod" +export type TailwindVersion = "v3" | "v4" | null + type ProjectInfo = { framework: Framework isSrcDir: boolean @@ -19,7 +21,7 @@ type ProjectInfo = { isTsx: boolean tailwindConfigFile: string | null tailwindCssFile: string | null - tailwindVersion: "v3" | "v4" | null + tailwindVersion: TailwindVersion aliasPrefix: string | null } @@ -168,7 +170,11 @@ export async function getTailwindCssFile(cwd: string) { tailwindVersion === "v4" ? `@import "tailwindcss"` : "@tailwind base" for (const file of files) { const contents = await fs.readFile(path.resolve(cwd, file), "utf8") - if (contents.includes(needle)) { + if ( + contents.includes(`@import "tailwindcss"`) || + contents.includes(`@import 'tailwindcss'`) || + contents.includes(`@tailwind base`) + ) { return file } } @@ -300,3 +306,19 @@ export async function getProjectConfig( return await resolveConfigPaths(cwd, config) } + +export async function getProjectTailwindVersionFromConfig( + config: Config +): Promise { + if (!config.resolvedPaths.cwd) { + return "v3" + } + + const projectInfo = await getProjectInfo(config.resolvedPaths.cwd) + + if (!projectInfo?.tailwindVersion) { + return null + } + + return projectInfo.tailwindVersion +} diff --git a/packages/shadcn/src/utils/updaters/update-css-vars.ts b/packages/shadcn/src/utils/updaters/update-css-vars.ts index f362961dd4a..8d4cb88753c 100644 --- a/packages/shadcn/src/utils/updaters/update-css-vars.ts +++ b/packages/shadcn/src/utils/updaters/update-css-vars.ts @@ -2,6 +2,7 @@ import { promises as fs } from "fs" import path from "path" import { registryItemCssVarsSchema } from "@/src/registry/schema" import { Config } from "@/src/utils/get-config" +import { TailwindVersion, getProjectInfo } from "@/src/utils/get-project-info" import { highlighter } from "@/src/utils/highlighter" import { spinner } from "@/src/utils/spinner" import postcss from "postcss" @@ -16,6 +17,7 @@ export async function updateCssVars( options: { cleanupDefaultNextStyles?: boolean silent?: boolean + tailwindVersion?: TailwindVersion } ) { if ( @@ -29,6 +31,7 @@ export async function updateCssVars( options = { cleanupDefaultNextStyles: false, silent: false, + tailwindVersion: "v3", ...options, } const cssFilepath = config.resolvedPaths.tailwindCss @@ -45,6 +48,7 @@ export async function updateCssVars( const raw = await fs.readFile(cssFilepath, "utf8") let output = await transformCssVars(raw, cssVars, config, { cleanupDefaultNextStyles: options.cleanupDefaultNextStyles, + tailwindVersion: options.tailwindVersion, }) await fs.writeFile(cssFilepath, output, "utf8") cssVarsSpinner.succeed() @@ -56,21 +60,32 @@ export async function transformCssVars( config: Config, options: { cleanupDefaultNextStyles?: boolean + tailwindVersion?: TailwindVersion } = { cleanupDefaultNextStyles: false, + tailwindVersion: "v3", } ) { options = { cleanupDefaultNextStyles: false, + tailwindVersion: "v3", ...options, } - const plugins = [updateCssVarsPlugin(cssVars)] + let plugins = [updateCssVarsPlugin(cssVars)] + + if (options.tailwindVersion === "v4") { + plugins = [ + addCustomVariant({ params: "dark (&:is(.dark *))" }), + updateCssVarsPluginV4(cssVars), + updateThemePlugin(cssVars), + ] + } + if (options.cleanupDefaultNextStyles) { plugins.push(cleanupDefaultNextStylesPlugin()) } - // Only add the base layer plugin if we're using css variables. if (config.tailwind.cssVariables) { plugins.push(updateBaseLayerPlugin()) } @@ -298,3 +313,126 @@ function addOrUpdateVars( existingDecl ? existingDecl.replaceWith(newDecl) : ruleNode?.append(newDecl) }) } + +function updateCssVarsPluginV4( + cssVars: z.infer +) { + return { + postcssPlugin: "update-css-vars-v4", + Once(root: Root) { + Object.entries(cssVars).forEach(([key, vars]) => { + const selector = key === "light" ? ":root" : `.${key}` + + let ruleNode = root.nodes?.find( + (node): node is Rule => + node.type === "rule" && node.selector === selector + ) + + if (!ruleNode) { + ruleNode = postcss.rule({ + selector, + nodes: [], + raws: { semicolon: true, between: " ", before: "\n" }, + }) + root.append(ruleNode) + } + + Object.entries(vars).forEach(([key, value]) => { + const prop = `--${key.replace(/^--/, "")}` + + if ( + !value.startsWith("hsl") && + !value.startsWith("rgb") && + !value.startsWith("#") && + !value.startsWith("oklch") + ) { + value = `hsl(${value})` + } + + const newDecl = postcss.decl({ + prop, + value, + raws: { semicolon: true }, + }) + const existingDecl = ruleNode?.nodes.find( + (node): node is postcss.Declaration => + node.type === "decl" && node.prop === prop + ) + existingDecl + ? existingDecl.replaceWith(newDecl) + : ruleNode?.append(newDecl) + }) + }) + }, + } +} + +function updateThemePlugin(cssVars: z.infer) { + return { + postcssPlugin: "update-theme", + Once(root: Root) { + let themeNode = root.nodes.find( + (node): node is AtRule => + node.type === "atrule" && + node.name === "theme" && + node.params === "inline" + ) + + if (!themeNode) { + themeNode = postcss.atRule({ + name: "theme", + params: "inline", + nodes: [], + raws: { semicolon: true, between: " ", before: "\n" }, + }) + root.append(themeNode) + } + + // Find unique color names from light and dark. + const colors = Array.from( + new Set( + Object.keys(cssVars).flatMap((key) => + Object.keys(cssVars[key as keyof typeof cssVars] || {}) + ) + ) + ) + + for (const color of colors) { + const colorVar = postcss.decl({ + prop: `--color-${color.replace(/^--/, "")}`, + value: `var(--${color})`, + raws: { semicolon: true }, + }) + const existingDecl = themeNode?.nodes?.find( + (node): node is postcss.Declaration => + node.type === "decl" && node.prop === colorVar.prop + ) + if (!existingDecl) { + themeNode?.append(colorVar) + } + } + }, + } +} + +function addCustomVariant({ params }: { params: string }) { + return { + postcssPlugin: "add-custom-variant", + Once(root: Root) { + const customVariant = root.nodes.find( + (node): node is AtRule => + node.type === "atrule" && node.name === "custom-variant" + ) + if (!customVariant) { + root.insertAfter( + root.nodes[0], + postcss.atRule({ + name: "custom-variant", + params, + raws: { semicolon: true, before: "\n" }, + }) + ) + } + }, + } +} diff --git a/packages/shadcn/src/utils/updaters/update-tailwind-config.ts b/packages/shadcn/src/utils/updaters/update-tailwind-config.ts index 56853fec697..cee693d4446 100644 --- a/packages/shadcn/src/utils/updaters/update-tailwind-config.ts +++ b/packages/shadcn/src/utils/updaters/update-tailwind-config.ts @@ -3,6 +3,7 @@ import { tmpdir } from "os" import path from "path" import { registryItemTailwindSchema } from "@/src/registry/schema" import { Config } from "@/src/utils/get-config" +import { TailwindVersion } from "@/src/utils/get-project-info" import { highlighter } from "@/src/utils/highlighter" import { spinner } from "@/src/utils/spinner" import deepmerge from "deepmerge" @@ -32,6 +33,7 @@ export async function updateTailwindConfig( config: Config, options: { silent?: boolean + tailwindVersion?: TailwindVersion } ) { if (!tailwindConfig) { @@ -40,9 +42,15 @@ export async function updateTailwindConfig( options = { silent: false, + tailwindVersion: "v3", ...options, } + // No tailwind config in v4. + if (options.tailwindVersion === "v4") { + return + } + const tailwindFileRelativePath = path.relative( config.resolvedPaths.cwd, config.resolvedPaths.tailwindConfig diff --git a/packages/shadcn/test/fixtures/frameworks/next-pages/styles/globals.css b/packages/shadcn/test/fixtures/frameworks/next-pages/styles/globals.css index f1d8c73cdcf..d4b5078586e 100644 --- a/packages/shadcn/test/fixtures/frameworks/next-pages/styles/globals.css +++ b/packages/shadcn/test/fixtures/frameworks/next-pages/styles/globals.css @@ -1 +1 @@ -@import "tailwindcss"; +@import 'tailwindcss'; diff --git a/packages/shadcn/test/utils/updaters/update-css-vars.test.ts b/packages/shadcn/test/utils/updaters/update-css-vars.test.ts index 5ab0f6743c6..641d8e67b1e 100644 --- a/packages/shadcn/test/utils/updaters/update-css-vars.test.ts +++ b/packages/shadcn/test/utils/updaters/update-css-vars.test.ts @@ -173,3 +173,379 @@ describe("transformCssVars", () => { `) }) }) + +describe("transformCssVarsV4", () => { + test("should transform css vars for v4", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + `, + { + light: { + background: "0 0% 100%", + foreground: "240 10% 3.9%", + }, + dark: { + background: "240 10% 3.9%", + foreground: "0 0% 98%", + }, + }, + { tailwind: { cssVariables: true } }, + { tailwindVersion: "v4" } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); + :root { + --background: hsl(0 0% 100%); + --foreground: hsl(240 10% 3.9%); + } + .dark { + --background: hsl(240 10% 3.9%); + --foreground: hsl(0 0% 98%); + } + @theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + } + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + test("should update light and dark css vars if present", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + :root { + --background: hsl(210 40% 98%); + } + + .dark { + --background: hsl(222.2 84% 4.9%); + } + `, + { + light: { + background: "215 20.2% 65.1%", + foreground: "222.2 84% 4.9%", + primary: "215 20.2% 65.1%", + }, + dark: { + foreground: "60 9.1% 97.8%", + primary: "oklch(0.72 0.11 178)", + }, + }, + { tailwind: { cssVariables: true } }, + { tailwindVersion: "v4" } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); + :root { + --background: hsl(215 20.2% 65.1%); + --foreground: hsl(222.2 84% 4.9%); + --primary: hsl(215 20.2% 65.1%); + } + + .dark { + --background: hsl(222.2 84% 4.9%); + --foreground: hsl(60 9.1% 97.8%); + --primary: oklch(0.72 0.11 178); + } + + @theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-primary: var(--primary); + } + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + test("should update theme vars if present", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + :root { + --background: hsl(210 40% 98%); + } + + .dark { + --background: hsl(222.2 84% 4.9%); + } + + @theme inline { + --color-background: var(--background); + } + `, + { + light: { + background: "215 20.2% 65.1%", + foreground: "222.2 84% 4.9%", + primary: "215 20.2% 65.1%", + }, + dark: { + foreground: "60 9.1% 97.8%", + primary: "222.2 84% 4.9%", + }, + }, + { tailwind: { cssVariables: true } }, + { tailwindVersion: "v4" } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); + :root { + --background: hsl(215 20.2% 65.1%); + --foreground: hsl(222.2 84% 4.9%); + --primary: hsl(215 20.2% 65.1%); + } + + .dark { + --background: hsl(222.2 84% 4.9%); + --foreground: hsl(60 9.1% 97.8%); + --primary: hsl(222.2 84% 4.9%); + } + + @theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-primary: var(--primary); + } + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + test("should not add base layer if it is already present", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + :root { + --background: hsl(210 40% 98%); + } + + .dark { + --background: hsl(222.2 84% 4.9%); + } + + @theme inline { + --color-background: var(--background); + } + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + `, + { + light: { + background: "215 20.2% 65.1%", + foreground: "222.2 84% 4.9%", + primary: "215 20.2% 65.1%", + }, + dark: { + foreground: "60 9.1% 97.8%", + primary: "222.2 84% 4.9%", + }, + }, + { tailwind: { cssVariables: true } }, + { tailwindVersion: "v4" } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); + :root { + --background: hsl(215 20.2% 65.1%); + --foreground: hsl(222.2 84% 4.9%); + --primary: hsl(215 20.2% 65.1%); + } + + .dark { + --background: hsl(222.2 84% 4.9%); + --foreground: hsl(60 9.1% 97.8%); + --primary: hsl(222.2 84% 4.9%); + } + + @theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-primary: var(--primary); + } + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + test("it should add the dark @custom-variant if not present", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + `, + { + light: { + background: "0 0% 100%", + foreground: "240 10% 3.9%", + }, + dark: { + background: "240 10% 3.9%", + foreground: "0 0% 98%", + }, + }, + { tailwind: { cssVariables: true } }, + { tailwindVersion: "v4" } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); + :root { + --background: hsl(0 0% 100%); + --foreground: hsl(240 10% 3.9%); + } + .dark { + --background: hsl(240 10% 3.9%); + --foreground: hsl(0 0% 98%); + } + @theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + } + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + test("it should only add hsl() if not already present", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + `, + { + light: { + background: "0 0% 100%", + foreground: "hsl(240 10% 3.9%)", + }, + dark: { + background: "hsl(240 10% 3.9%)", + foreground: "0 0% 98%", + }, + }, + { tailwind: { cssVariables: true } }, + { tailwindVersion: "v4" } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); + :root { + --background: hsl(0 0% 100%); + --foreground: hsl(240 10% 3.9%); + } + .dark { + --background: hsl(240 10% 3.9%); + --foreground: hsl(0 0% 98%); + } + @theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + } + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + test("it should only add hsl() for rgb and hex values", async () => { + expect( + await transformCssVars( + `@import "tailwindcss"; + `, + { + light: { + background: "rgb(255, 255, 255)", + foreground: "hsl(240 10% 3.9%)", + }, + dark: { + background: "hsl(240 10% 3.9%)", + foreground: "#000fff", + }, + }, + { tailwind: { cssVariables: true } }, + { tailwindVersion: "v4" } + ) + ).toMatchInlineSnapshot(` + "@import "tailwindcss"; + @custom-variant dark (&:is(.dark *)); + :root { + --background: rgb(255, 255, 255); + --foreground: hsl(240 10% 3.9%); + } + .dark { + --background: hsl(240 10% 3.9%); + --foreground: #000fff; + } + @theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + } + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) +})