Skip to content

Commit e84c819

Browse files
authored
feat(shadcn): update handling of import and apply at rules (#8109)
* fix: plugin imports * fix(shadcn): import in css * feat(shadcn): allow empty body for apply rules * chore: changeset * fix: type issue
1 parent 64f8baf commit e84c819

5 files changed

Lines changed: 434 additions & 88 deletions

File tree

.changeset/solid-seas-wear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"shadcn": minor
3+
---
4+
5+
update handling of import and apply at rules

apps/v4/public/schema/registry-item.json

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -163,32 +163,7 @@
163163
"type": "object",
164164
"description": "CSS definitions to be added to the project's CSS file. Supports at-rules, selectors, nested rules, utilities, layers, and more.",
165165
"additionalProperties": {
166-
"oneOf": [
167-
{
168-
"type": "string",
169-
"description": "Direct CSS string (e.g., 'font-family: sans-serif; line-height: 1.5;')"
170-
},
171-
{
172-
"type": "object",
173-
"description": "CSS properties or nested selectors",
174-
"additionalProperties": {
175-
"oneOf": [
176-
{
177-
"type": "string",
178-
"description": "CSS property value (e.g., 'blue', 'var(--color-primary)')"
179-
},
180-
{
181-
"type": "object",
182-
"description": "Nested selector or rule with properties",
183-
"additionalProperties": {
184-
"type": "string",
185-
"description": "CSS property value for nested rule"
186-
}
187-
}
188-
]
189-
}
190-
}
191-
]
166+
"$ref": "#/definitions/cssValue"
192167
}
193168
},
194169
"envVars": {
@@ -219,5 +194,22 @@
219194
"description": "The name of the registry item to extend. This is used to extend the base shadcn/ui style. Set to none to start fresh. This is available for registry:style items only."
220195
}
221196
},
222-
"required": ["name", "type"]
197+
"required": ["name", "type"],
198+
"definitions": {
199+
"cssValue": {
200+
"oneOf": [
201+
{
202+
"type": "string",
203+
"description": "CSS property value or direct CSS string"
204+
},
205+
{
206+
"type": "object",
207+
"description": "Nested CSS properties, selectors, or at-rules. Empty objects are allowed for at-rules with no body.",
208+
"additionalProperties": {
209+
"$ref": "#/definitions/cssValue"
210+
}
211+
}
212+
]
213+
}
214+
}
223215
}

packages/shadcn/src/registry/schema.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,19 +52,13 @@ export const registryItemCssVarsSchema = z.object({
5252
dark: z.record(z.string(), z.string()).optional(),
5353
})
5454

55-
export const registryItemCssSchema = z.record(
56-
z.string(),
57-
z.lazy(() =>
58-
z.union([
59-
z.string(),
60-
z.record(
61-
z.string(),
62-
z.union([z.string(), z.record(z.string(), z.string())])
63-
),
64-
])
65-
)
55+
// Recursive type for CSS properties that supports empty objects at any level.
56+
const cssValueSchema: z.ZodType<any> = z.lazy(() =>
57+
z.union([z.string(), z.record(z.string(), cssValueSchema)])
6658
)
6759

60+
export const registryItemCssSchema = z.record(z.string(), cssValueSchema)
61+
6862
export const registryItemEnvVarsSchema = z.record(z.string(), z.string())
6963

7064
export const registryItemSchema = z.object({

packages/shadcn/src/utils/updaters/update-css.ts

Lines changed: 128 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ export async function transformCss(
6060
})
6161

6262
let output = result.css
63+
64+
// PostCSS doesn't add semicolons to at-rules without bodies when they're the last node.
65+
// We need to manually ensure they have semicolons.
66+
const root = result.root
67+
if (root.nodes && root.nodes.length > 0) {
68+
const lastNode = root.nodes[root.nodes.length - 1]
69+
if (
70+
lastNode.type === "atrule" &&
71+
!lastNode.nodes &&
72+
!output.trimEnd().endsWith(";")
73+
) {
74+
output = output.trimEnd() + ";"
75+
}
76+
}
77+
6378
output = output.replace(/\/\* ---break--- \*\//g, "")
6479
output = output.replace(/(\n\s*\n)+/g, "\n\n")
6580
output = output.trimEnd()
@@ -79,20 +94,77 @@ function updateCssPlugin(css: z.infer<typeof registryItemCssSchema>) {
7994

8095
const [, name, params] = atRuleMatch
8196

82-
// Special handling for plugins - place them after imports
83-
if (name === "plugin") {
84-
// Find existing plugin with same params
85-
const existingPlugin = root.nodes?.find(
97+
// Special handling for imports - place them at the top.
98+
if (name === "import") {
99+
// Check if this import already exists.
100+
const existingImport = root.nodes?.find(
86101
(node): node is AtRule =>
87102
node.type === "atrule" &&
88-
node.name === "plugin" &&
103+
node.name === "import" &&
89104
node.params === params
90105
)
91106

107+
if (!existingImport) {
108+
const importRule = postcss.atRule({
109+
name: "import",
110+
params,
111+
raws: { semicolon: true },
112+
})
113+
114+
// Find the last import to insert after, or insert at beginning.
115+
const importNodes = root.nodes?.filter(
116+
(node): node is AtRule =>
117+
node.type === "atrule" && node.name === "import"
118+
)
119+
120+
if (importNodes && importNodes.length > 0) {
121+
// Insert after the last existing import.
122+
const lastImport = importNodes[importNodes.length - 1]
123+
importRule.raws.before = "\n"
124+
root.insertAfter(lastImport, importRule)
125+
} else {
126+
// No imports exist, insert at the very beginning.
127+
// Check if the file is empty.
128+
if (!root.nodes || root.nodes.length === 0) {
129+
importRule.raws.before = ""
130+
} else {
131+
importRule.raws.before = ""
132+
}
133+
root.prepend(importRule)
134+
}
135+
}
136+
}
137+
// Special handling for plugins - place them after imports.
138+
else if (name === "plugin") {
139+
// Ensure plugin name is quoted if not already.
140+
let quotedParams = params
141+
if (params && !params.startsWith('"') && !params.startsWith("'")) {
142+
quotedParams = `"${params}"`
143+
}
144+
145+
// Normalize params for comparison (remove quotes).
146+
const normalizeParams = (p: string) => {
147+
if (p.startsWith('"') && p.endsWith('"')) {
148+
return p.slice(1, -1)
149+
}
150+
if (p.startsWith("'") && p.endsWith("'")) {
151+
return p.slice(1, -1)
152+
}
153+
return p
154+
}
155+
156+
// Find existing plugin with same normalized params.
157+
const existingPlugin = root.nodes?.find((node): node is AtRule => {
158+
if (node.type !== "atrule" || node.name !== "plugin") {
159+
return false
160+
}
161+
return normalizeParams(node.params) === normalizeParams(params)
162+
})
163+
92164
if (!existingPlugin) {
93165
const pluginRule = postcss.atRule({
94166
name: "plugin",
95-
params,
167+
params: quotedParams,
96168
raws: { semicolon: true, before: "\n" },
97169
})
98170

@@ -141,7 +213,34 @@ function updateCssPlugin(css: z.infer<typeof registryItemCssSchema>) {
141213
}
142214
}
143215
}
144-
// Special handling for keyframes - place them under @theme inline
216+
// Check if this is any at-rule with no body (empty object).
217+
else if (
218+
typeof properties === "object" &&
219+
Object.keys(properties).length === 0
220+
) {
221+
// Handle any at-rule with no body (e.g., @apply, @tailwind, etc.).
222+
const atRule = root.nodes?.find(
223+
(node): node is AtRule =>
224+
node.type === "atrule" &&
225+
node.name === name &&
226+
node.params === params
227+
) as AtRule | undefined
228+
229+
if (!atRule) {
230+
const newAtRule = postcss.atRule({
231+
name,
232+
params,
233+
raws: { semicolon: true },
234+
})
235+
236+
root.append(newAtRule)
237+
root.insertBefore(
238+
newAtRule,
239+
postcss.comment({ text: "---break---" })
240+
)
241+
}
242+
}
243+
// Special handling for keyframes - place them under @theme inline.
145244
else if (name === "keyframes") {
146245
let themeInline = root.nodes?.find(
147246
(node): node is AtRule =>
@@ -339,25 +438,43 @@ function processRule(parent: Root | AtRule, selector: string, properties: any) {
339438

340439
if (typeof properties === "object") {
341440
for (const [prop, value] of Object.entries(properties)) {
342-
if (typeof value === "string") {
441+
// Check if this is any at-rule with empty object (no body).
442+
if (
443+
prop.startsWith("@") &&
444+
typeof value === "object" &&
445+
value !== null &&
446+
Object.keys(value).length === 0
447+
) {
448+
// Parse the at-rule.
449+
const atRuleMatch = prop.match(/@([a-zA-Z-]+)\s*(.*)/)
450+
if (atRuleMatch) {
451+
const [, atRuleName, atRuleParams] = atRuleMatch
452+
const atRule = postcss.atRule({
453+
name: atRuleName,
454+
params: atRuleParams,
455+
raws: { semicolon: true, before: "\n " },
456+
})
457+
rule.append(atRule)
458+
}
459+
} else if (typeof value === "string") {
343460
const decl = postcss.decl({
344461
prop,
345462
value: value,
346463
raws: { semicolon: true, before: "\n " },
347464
})
348465

349-
// Replace existing property or add new one
466+
// Replace existing property or add new one.
350467
const existingDecl = rule.nodes?.find(
351468
(node): node is Declaration =>
352469
node.type === "decl" && node.prop === prop
353470
)
354471

355472
existingDecl ? existingDecl.replaceWith(decl) : rule.append(decl)
356473
} else if (typeof value === "object") {
357-
// Nested selector (including & selectors)
474+
// Nested selector (including & selectors).
358475
const nestedSelector = prop.startsWith("&")
359476
? selector.replace(/^([^:]+)/, `$1${prop.substring(1)}`)
360-
: prop // Use the original selector for other nested elements
477+
: prop // Use the original selector for other nested elements.
361478
processRule(parent, nestedSelector, value)
362479
}
363480
}

0 commit comments

Comments
 (0)