Merge pull request #9592 from shadcn-ui/shadcn/fix-base-layer-handling

feat: update handling of base styles
This commit is contained in:
shadcn
2026-02-12 11:36:32 +04:00
committed by GitHub
11 changed files with 169 additions and 148 deletions

View File

@@ -16,7 +16,16 @@
"files": [],
"cssVars": {},
"css": {
"@import \"shadcn/tailwind.css\"": {}
"@import \"tw-animate-css\"": {},
"@import \"shadcn/tailwind.css\"": {},
"@layer base": {
"*": {
"@apply border-border outline-ring/50": {}
},
"body": {
"@apply bg-background text-foreground": {}
}
}
},
"type": "registry:style"
}

View File

@@ -19,7 +19,16 @@
"files": [],
"cssVars": {},
"css": {
"@import \"shadcn/tailwind.css\"": {}
"@import \"tw-animate-css\"": {},
"@import \"shadcn/tailwind.css\"": {},
"@layer base": {
"*": {
"@apply border-border outline-ring/50": {}
},
"body": {
"@apply bg-background text-foreground": {}
}
}
},
"type": "registry:style"
},
@@ -40,7 +49,16 @@
"files": [],
"cssVars": {},
"css": {
"@import \"shadcn/tailwind.css\"": {}
"@import \"tw-animate-css\"": {},
"@import \"shadcn/tailwind.css\"": {},
"@layer base": {
"*": {
"@apply border-border outline-ring/50": {}
},
"body": {
"@apply bg-background text-foreground": {}
}
}
},
"type": "registry:style"
},

View File

@@ -16,7 +16,16 @@
"files": [],
"cssVars": {},
"css": {
"@import \"shadcn/tailwind.css\"": {}
"@import \"tw-animate-css\"": {},
"@import \"shadcn/tailwind.css\"": {},
"@layer base": {
"*": {
"@apply border-border outline-ring/50": {}
},
"body": {
"@apply bg-background text-foreground": {}
}
}
},
"type": "registry:style"
}

View File

@@ -26,7 +26,16 @@ const NEW_YORK_V4_STYLE = {
devDependencies: ["tw-animate-css", "shadcn"],
registryDependencies: ["utils"],
css: {
'@import "tw-animate-css"': {},
'@import "shadcn/tailwind.css"': {},
"@layer base": {
"*": {
"@apply border-border outline-ring/50": {},
},
body: {
"@apply bg-background text-foreground": {},
},
},
},
cssVars: {},
files: [],

View File

@@ -90,20 +90,19 @@ export const add = new Command()
}
let itemType: z.infer<typeof registryItemTypeSchema> | undefined
let shouldInstallBaseStyle = true
let shouldInstallStyleIndex = true
if (components.length > 0) {
const [registryItem] = await getRegistryItems([components[0]], {
config: initialConfig,
})
itemType = registryItem?.type
shouldInstallBaseStyle =
itemType !== "registry:theme" && itemType !== "registry:style"
shouldInstallStyleIndex =
itemType !== "registry:theme" &&
itemType !== "registry:style" &&
itemType !== "registry:base"
if (isUniversalRegistryItem(registryItem)) {
await addComponents(components, initialConfig, {
...options,
baseStyle: shouldInstallBaseStyle,
})
await addComponents(components, initialConfig, options)
return
}
@@ -180,8 +179,8 @@ export const add = new Command()
isNewProject: false,
srcDir: options.srcDir,
cssVariables: options.cssVariables,
baseStyle: shouldInstallBaseStyle,
baseColor: shouldInstallBaseStyle ? undefined : "neutral",
installStyleIndex: shouldInstallStyleIndex,
baseColor: shouldInstallStyleIndex ? undefined : "neutral",
components: options.components,
})
initHasRun = true
@@ -216,8 +215,8 @@ export const add = new Command()
isNewProject: true,
srcDir: options.srcDir,
cssVariables: options.cssVariables,
baseStyle: shouldInstallBaseStyle,
baseColor: shouldInstallBaseStyle ? undefined : "neutral",
installStyleIndex: shouldInstallStyleIndex,
baseColor: shouldInstallStyleIndex ? undefined : "neutral",
components: options.components,
})
initHasRun = true
@@ -244,10 +243,7 @@ export const add = new Command()
config = updatedConfig
if (!initHasRun) {
await addComponents(options.components, config, {
...options,
baseStyle: shouldInstallBaseStyle,
})
await addComponents(options.components, config, options)
}
// If we're adding a single component and it's from the v0 registry,

View File

@@ -204,7 +204,7 @@ export const create = new Command()
rtl: opts.rtl,
template,
baseColor,
baseStyle: false,
installStyleIndex: false,
registryBaseConfig,
skipPreflight: false,
})
@@ -218,7 +218,6 @@ export const create = new Command()
components.push("direction")
}
await addComponents(components, config, {
baseStyle: false,
silent: true,
overwrite: true,
})

View File

@@ -104,7 +104,7 @@ export const initOptionsSchema = z.object({
).join("', '")}'`,
}
),
baseStyle: z.boolean(),
installStyleIndex: z.boolean(),
// Config from registry:base item to merge into components.json.
registryBaseConfig: rawConfigSchema.deepPartial().optional(),
})
@@ -157,6 +157,7 @@ export const init = new Command()
isNewProject: false,
components,
...opts,
installStyleIndex: opts.baseStyle,
})
await loadEnvFiles(options.cwd)
@@ -228,8 +229,8 @@ export const init = new Command()
// Store config to be merged into components.json later.
options.registryBaseConfig = item.config
}
options.baseStyle =
item.extends === "none" ? false : options.baseStyle
options.installStyleIndex =
item.extends === "none" ? false : options.installStyleIndex
}
if (item?.type === "registry:style") {
@@ -238,14 +239,13 @@ export const init = new Command()
options.baseColor = "neutral"
// If the style extends none, we don't want to install the base style.
options.baseStyle =
item.extends === "none" ? false : options.baseStyle
options.installStyleIndex =
item.extends === "none" ? false : options.installStyleIndex
}
}
// If --no-base-style, we don't want to prompt for a base color either.
// The style will extend or override it.
if (!options.baseStyle) {
if (!options.installStyleIndex) {
options.baseColor = "neutral"
}
@@ -326,7 +326,7 @@ export async function runInit(
// Why index? Because when style is true, we read style from components.json and fetch that.
// i.e new-york from components.json then fetch /styles/new-york/index.
// TODO: Fix this so that we can extend any style i.e --style=new-york.
...(options.baseStyle ? ["index"] : []),
...(options.installStyleIndex ? ["index"] : []),
...(options.components ?? []),
]
@@ -389,7 +389,6 @@ export async function runInit(
// Init will always overwrite files.
overwrite: true,
silent: options.silent,
baseStyle: options.baseStyle,
isNewProject:
options.isNewProject || projectInfo?.framework.name === "next-app",
})

View File

@@ -38,7 +38,6 @@ export async function addComponents(
overwrite?: boolean
silent?: boolean
isNewProject?: boolean
baseStyle?: boolean
registryHeaders?: Record<string, Record<string, string>>
path?: string
}
@@ -47,7 +46,6 @@ export async function addComponents(
overwrite: false,
silent: false,
isNewProject: false,
baseStyle: true,
...options,
}
@@ -74,11 +72,10 @@ async function addProjectComponents(
overwrite?: boolean
silent?: boolean
isNewProject?: boolean
baseStyle?: boolean
path?: string
}
) {
if (!options.baseStyle && !components.length) {
if (!components.length) {
return
}
@@ -117,7 +114,6 @@ async function addProjectComponents(
tailwindVersion,
tailwindConfig: tree.tailwind?.config,
overwriteCssVars,
initIndex: options.baseStyle,
})
// Add CSS updater
@@ -157,11 +153,10 @@ async function addWorkspaceComponents(
silent?: boolean
isNewProject?: boolean
isRemote?: boolean
baseStyle?: boolean
path?: string
}
) {
if (!options.baseStyle && !components.length) {
if (!components.length) {
return
}

View File

@@ -5,7 +5,6 @@ import {
registryItemTailwindSchema,
} from "@/src/schema"
import { Config } from "@/src/utils/get-config"
import { getPackageInfo } from "@/src/utils/get-package-info"
import { TailwindVersion } from "@/src/utils/get-project-info"
import { highlighter } from "@/src/utils/highlighter"
import { spinner } from "@/src/utils/spinner"
@@ -21,7 +20,6 @@ export async function updateCssVars(
options: {
cleanupDefaultNextStyles?: boolean
overwriteCssVars?: boolean
initIndex?: boolean
silent?: boolean
tailwindVersion?: TailwindVersion
tailwindConfig?: z.infer<typeof registryItemTailwindSchema>["config"]
@@ -36,7 +34,6 @@ export async function updateCssVars(
silent: false,
tailwindVersion: "v3",
overwriteCssVars: false,
initIndex: true,
...options,
}
const cssFilepath = config.resolvedPaths.tailwindCss
@@ -56,7 +53,6 @@ export async function updateCssVars(
tailwindVersion: options.tailwindVersion,
tailwindConfig: options.tailwindConfig,
overwriteCssVars: options.overwriteCssVars,
initIndex: options.initIndex,
})
await fs.writeFile(cssFilepath, output, "utf8")
cssVarsSpinner.succeed()
@@ -71,13 +67,11 @@ export async function transformCssVars(
tailwindVersion?: TailwindVersion
tailwindConfig?: z.infer<typeof registryItemTailwindSchema>["config"]
overwriteCssVars?: boolean
initIndex?: boolean
} = {
cleanupDefaultNextStyles: false,
tailwindVersion: "v3",
tailwindConfig: undefined,
overwriteCssVars: false,
initIndex: false,
}
) {
options = {
@@ -85,7 +79,6 @@ export async function transformCssVars(
tailwindVersion: "v3",
tailwindConfig: undefined,
overwriteCssVars: false,
initIndex: false,
...options,
}
@@ -98,18 +91,6 @@ export async function transformCssVars(
if (options.tailwindVersion === "v4") {
plugins = []
// Only add tw-animate-css if project does not have tailwindcss-animate
if (config.resolvedPaths?.cwd) {
const packageInfo = getPackageInfo(config.resolvedPaths.cwd)
if (
!packageInfo?.dependencies?.["tailwindcss-animate"] &&
!packageInfo?.devDependencies?.["tailwindcss-animate"] &&
options.initIndex
) {
plugins.push(addCustomImport({ params: "tw-animate-css" }))
}
}
plugins.push(addCustomVariant({ params: "dark (&:is(.dark *))" }))
if (options.cleanupDefaultNextStyles) {
@@ -130,12 +111,6 @@ export async function transformCssVars(
}
}
if (config.tailwind.cssVariables && options.initIndex) {
plugins.push(
updateBaseLayerPlugin({ tailwindVersion: options.tailwindVersion })
)
}
const result = await postcss(plugins).process(input, {
from: undefined,
})
@@ -151,81 +126,6 @@ export async function transformCssVars(
return output
}
function updateBaseLayerPlugin({
tailwindVersion,
}: {
tailwindVersion?: TailwindVersion
}) {
return {
postcssPlugin: "update-base-layer",
Once(root: Root) {
const requiredRules = [
{
selector: "*",
apply:
tailwindVersion === "v4"
? "border-border outline-ring/50"
: "border-border",
},
{ selector: "body", apply: "bg-background text-foreground" },
]
let baseLayer = root.nodes.find(
(node): node is AtRule =>
node.type === "atrule" &&
node.name === "layer" &&
node.params === "base" &&
requiredRules.every(({ selector, apply }) =>
node.nodes?.some(
(rule): rule is Rule =>
rule.type === "rule" &&
rule.selector === selector &&
rule.nodes.some(
(applyRule): applyRule is AtRule =>
applyRule.type === "atrule" &&
applyRule.name === "apply" &&
applyRule.params === apply
)
)
)
) as AtRule | undefined
if (!baseLayer) {
baseLayer = postcss.atRule({
name: "layer",
params: "base",
raws: { semicolon: true, between: " ", before: "\n" },
})
root.append(baseLayer)
root.insertBefore(baseLayer, postcss.comment({ text: "---break---" }))
}
requiredRules.forEach(({ selector, apply }) => {
const existingRule = baseLayer?.nodes?.find(
(node): node is Rule =>
node.type === "rule" && node.selector === selector
)
if (!existingRule) {
baseLayer?.append(
postcss.rule({
selector,
nodes: [
postcss.atRule({
name: "apply",
params: apply,
raws: { semicolon: true, before: "\n " },
}),
],
raws: { semicolon: true, between: " ", before: "\n " },
})
)
}
})
},
}
}
function updateCssVarsPlugin(
cssVars: z.infer<typeof registryItemCssVarsSchema>
) {
@@ -662,13 +562,13 @@ function addCustomImport({ params }: { params: string }) {
node.type === "atrule" && node.name === "import"
)
// Find custom variant node (to ensure we insert before it)
// Find custom variant node (to ensure we insert before it).
const customVariantNode = root.nodes.find(
(node): node is AtRule =>
node.type === "atrule" && node.name === "custom-variant"
)
// Check if our specific import already exists
// Check if our specific import already exists.
const hasImport = importNodes.some(
(node) => node.params.replace(/["']/g, "") === params
)
@@ -681,18 +581,18 @@ function addCustomImport({ params }: { params: string }) {
})
if (importNodes.length > 0) {
// If there are existing imports, add after the last import
// If there are existing imports, add after the last import.
const lastImport = importNodes[importNodes.length - 1]
root.insertAfter(lastImport, importNode)
} else if (customVariantNode) {
// If no imports but has custom-variant, insert before it
// If no imports but has custom-variant, insert before it.
root.insertBefore(customVariantNode, importNode)
root.insertBefore(
customVariantNode,
postcss.comment({ text: "---break---" })
)
} else {
// If no imports and no custom-variant, insert at the start
// If no imports and no custom-variant, insert at the start.
root.prepend(importNode)
root.insertAfter(importNode, postcss.comment({ text: "---break---" }))
}

View File

@@ -472,12 +472,23 @@ function processRule(parent: Root | AtRule, selector: string, properties: any) {
const atRuleMatch = prop.match(/@([a-zA-Z-]+)\s*(.*)/)
if (atRuleMatch) {
const [, atRuleName, atRuleParams] = atRuleMatch
const atRule = postcss.atRule({
name: atRuleName,
params: atRuleParams,
raws: { semicolon: true, before: "\n " },
})
rule.append(atRule)
// Check if this at-rule already exists in the rule.
const existingAtRule = rule.nodes?.find(
(node): node is AtRule =>
node.type === "atrule" &&
node.name === atRuleName &&
node.params === atRuleParams
)
if (!existingAtRule) {
const atRule = postcss.atRule({
name: atRuleName,
params: atRuleParams,
raws: { semicolon: true, before: "\n " },
})
rule.append(atRule)
}
}
} else if (typeof value === "string") {
const decl = postcss.decl({

View File

@@ -853,6 +853,82 @@ describe("transformCss", () => {
`)
})
test("should add base layer styles from registry:style css field", async () => {
const input = `@import "tailwindcss";`
// This is the exact shape from the registry:style index item.
const result = await transformCss(input, {
'@import "tw-animate-css"': {},
'@import "shadcn/tailwind.css"': {},
"@layer base": {
"*": {
"@apply border-border outline-ring/50": {},
},
body: {
"@apply bg-background text-foreground": {},
},
},
})
expect(result).toMatchInlineSnapshot(`
"@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}"
`)
})
test("should not duplicate base layer styles if already present", async () => {
const input = `@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}`
const result = await transformCss(input, {
'@import "tw-animate-css"': {},
'@import "shadcn/tailwind.css"': {},
"@layer base": {
"*": {
"@apply border-border outline-ring/50": {},
},
body: {
"@apply bg-background text-foreground": {},
},
},
})
expect(result).toMatchInlineSnapshot(`
"@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}"
`)
})
test("should replace existing keyframes instead of duplicating", async () => {
const input = `@import "tailwindcss";