diff --git a/.changeset/ten-gifts-check.md b/.changeset/ten-gifts-check.md new file mode 100644 index 0000000000..c0cb133c59 --- /dev/null +++ b/.changeset/ten-gifts-check.md @@ -0,0 +1,5 @@ +--- +"shadcn": patch +--- + +handling of apply directive inside utility diff --git a/apps/v4/content/docs/components/base/data-table.mdx b/apps/v4/content/docs/components/base/data-table.mdx index 4cbc976d34..23295d86e5 100644 --- a/apps/v4/content/docs/components/base/data-table.mdx +++ b/apps/v4/content/docs/components/base/data-table.mdx @@ -472,7 +472,7 @@ Let's make the email column sortable. ### Update `` -```tsx showLineNumbers title="app/payments/data-table.tsx" showLineNumbers {3,6,10,18,25-28} +```tsx showLineNumbers title="app/payments/data-table.tsx" showLineNumbers {3,6,10,18,25-29} "use client" import * as React from "react" diff --git a/apps/v4/content/docs/registry/registry-item-json.mdx b/apps/v4/content/docs/registry/registry-item-json.mdx index 8266b5d334..888bfaa258 100644 --- a/apps/v4/content/docs/registry/registry-item-json.mdx +++ b/apps/v4/content/docs/registry/registry-item-json.mdx @@ -18,6 +18,7 @@ The `registry-item.json` schema is used to define your custom registry items. "https://example.com/r/foo" ], "dependencies": ["is-even@3.0.0", "motion"], + "devDependencies": ["tw-animate-css"], "files": [ { "path": "registry/new-york/hello-world/hello-world.tsx", @@ -144,6 +145,21 @@ Use `@version` to specify the version of your registry item. } ``` +### devDependencies + +The `devDependencies` property is used to specify the dev dependencies of your registry item. These are `npm` packages that are only needed during development. + +Use `@version` to specify the version of the package. + +```json title="registry-item.json" showLineNumbers +{ + "devDependencies": [ + "tw-animate-css", + "name@1.2.0" + ] +} +``` + ### registryDependencies Used for registry dependencies. Can be names, namespaced or URLs. diff --git a/apps/v4/public/r/registries.json b/apps/v4/public/r/registries.json index a9502c3063..a2f2eae219 100644 --- a/apps/v4/public/r/registries.json +++ b/apps/v4/public/r/registries.json @@ -11,6 +11,12 @@ "url": "https://ui.8starlabs.com/r/{name}.json", "description": "A set of beautifully designed components designed for developers who want niche, high-utility UI elements that you won't find in standard libraries." }, + { + "name": "@auth0", + "homepage": "https://auth0.com", + "url": "https://ui.auth0.com/r/{name}.json", + "description": "Official Auth0 Universal Components for Web. Accelerate development with pre-built, embeddable UI for enterprise SSO, MFA, and organization management" + }, { "name": "@abui", "homepage": "https://abui.io", @@ -527,6 +533,12 @@ "url": "https://shadcnui-blocks.com/r/{name}.json", "description": "A collection of premium, production-ready shadcn/ui blocks, components and templates." }, + { + "name": "@shadcnuikit", + "homepage": "https://shadcnuikit.com", + "url": "https://shadcnuikit.com/r/{name}.json", + "description": "Launch your projects faster with admin dashboards, website templates, components, blocks, and pre-built real-world examples." + }, { "name": "@shadcraft", "homepage": "https://free.shadcraft.com", @@ -783,6 +795,6 @@ "name": "@pixelact-ui", "homepage": "https://pixelactui.com", "url": "https://pixelactui.com/r/{name}.json", - "description": "Playful pixel-art style components library built on top of shadcn" + "description": "Playful pixel art style components library built on top of shadcn. Perfect for retro style projects and games." } ] \ No newline at end of file diff --git a/apps/v4/registry/directory.json b/apps/v4/registry/directory.json index 3ff85d58e1..263c8e0536 100644 --- a/apps/v4/registry/directory.json +++ b/apps/v4/registry/directory.json @@ -13,6 +13,13 @@ "description": "A set of beautifully designed components designed for developers who want niche, high-utility UI elements that you won't find in standard libraries.", "logo": " " }, + { + "name": "@auth0", + "homepage": "https://auth0.com", + "url": "https://ui.auth0.com/r/{name}.json", + "description": "Official Auth0 Universal Components for Web. Accelerate development with pre-built, embeddable UI for enterprise SSO, MFA, and organization management", + "logo": "" + }, { "name": "@abui", "homepage": "https://abui.io", @@ -622,6 +629,13 @@ "description": "A collection of premium, production-ready shadcn/ui blocks, components and templates.", "logo": "" }, + { + "name": "@shadcnuikit", + "homepage": "https://shadcnuikit.com", + "url": "https://shadcnuikit.com/r/{name}.json", + "description": "Launch your projects faster with admin dashboards, website templates, components, blocks, and pre-built real-world examples.", + "logo": " " + }, { "name": "@shadcraft", "homepage": "https://free.shadcraft.com", @@ -920,7 +934,7 @@ "name": "@pixelact-ui", "homepage": "https://pixelactui.com", "url": "https://pixelactui.com/r/{name}.json", - "description": "Playful pixel-art style components library built on top of shadcn", + "description": "Playful pixel art style components library built on top of shadcn. Perfect for retro style projects and games.", "logo": "" } ] diff --git a/packages/shadcn/src/registry/errors.ts b/packages/shadcn/src/registry/errors.ts index fff5d4d309..42dd47fdbc 100644 --- a/packages/shadcn/src/registry/errors.ts +++ b/packages/shadcn/src/registry/errors.ts @@ -5,6 +5,7 @@ export const RegistryErrorCode = { // Network errors NETWORK_ERROR: "NETWORK_ERROR", NOT_FOUND: "NOT_FOUND", + GONE: "GONE", UNAUTHORIZED: "UNAUTHORIZED", FORBIDDEN: "FORBIDDEN", FETCH_ERROR: "FETCH_ERROR", @@ -90,6 +91,22 @@ export class RegistryNotFoundError extends RegistryError { } } +export class RegistryGoneError extends RegistryError { + constructor(public readonly url: string, cause?: unknown) { + const message = `The item at ${url} is no longer available. It may have been removed or expired.` + + super(message, { + code: RegistryErrorCode.GONE, + statusCode: 410, + cause, + context: { url }, + suggestion: + "This resource was previously available but has been permanently removed. Check if a newer version exists or contact the registry maintainer.", + }) + this.name = "RegistryGoneError" + } +} + export class RegistryUnauthorizedError extends RegistryError { constructor(public readonly url: string, cause?: unknown) { const message = `You are not authorized to access the item at ${url}. If this is a remote registry, you may need to authenticate.` diff --git a/packages/shadcn/src/registry/fetcher.test.ts b/packages/shadcn/src/registry/fetcher.test.ts index 7061e53e1a..0348db6c0b 100644 --- a/packages/shadcn/src/registry/fetcher.test.ts +++ b/packages/shadcn/src/registry/fetcher.test.ts @@ -2,6 +2,7 @@ import { REGISTRY_URL } from "@/src/registry/constants" import { RegistryFetchError, RegistryForbiddenError, + RegistryGoneError, RegistryNotFoundError, RegistryUnauthorizedError, } from "@/src/registry/errors" @@ -30,6 +31,9 @@ const server = setupServer( http.get(`${REGISTRY_URL}/forbidden.json`, () => { return new HttpResponse(null, { status: 403 }) }), + http.get(`${REGISTRY_URL}/gone.json`, () => { + return new HttpResponse(null, { status: 410 }) + }), http.get("https://external.com/component.json", () => { return HttpResponse.json({ name: "external", @@ -123,6 +127,10 @@ describe("fetchRegistry", () => { ) }) + it("should handle 410 errors", async () => { + await expect(fetchRegistry(["gone.json"])).rejects.toThrow(RegistryGoneError) + }) + it("should handle network errors", async () => { await expect(fetchRegistry(["error.json"])).rejects.toThrow() }) diff --git a/packages/shadcn/src/registry/fetcher.ts b/packages/shadcn/src/registry/fetcher.ts index 0e87aa50c7..e5df988310 100644 --- a/packages/shadcn/src/registry/fetcher.ts +++ b/packages/shadcn/src/registry/fetcher.ts @@ -6,6 +6,7 @@ import { getRegistryHeadersFromContext } from "@/src/registry/context" import { RegistryFetchError, RegistryForbiddenError, + RegistryGoneError, RegistryLocalFileError, RegistryNotFoundError, RegistryParseError, @@ -93,6 +94,10 @@ export async function fetchRegistry( throw new RegistryNotFoundError(url, messageFromServer) } + if (response.status === 410) { + throw new RegistryGoneError(url, messageFromServer) + } + if (response.status === 403) { throw new RegistryForbiddenError(url, messageFromServer) } diff --git a/packages/shadcn/src/utils/updaters/update-css.ts b/packages/shadcn/src/utils/updaters/update-css.ts index 37243225f5..b474891cfc 100644 --- a/packages/shadcn/src/utils/updaters/update-css.ts +++ b/packages/shadcn/src/utils/updaters/update-css.ts @@ -317,7 +317,7 @@ function updateCssPlugin(css: z.infer) { postcss.comment({ text: "---break---" }) ) - // Add declarations with their values preserved + // Add declarations with their values preserved. if (typeof properties === "object") { for (const [prop, value] of Object.entries(properties)) { if (typeof value === "string") { @@ -327,13 +327,38 @@ function updateCssPlugin(css: z.infer) { raws: { semicolon: true, before: "\n " }, }) atRule.append(decl) + } else if ( + prop.startsWith("@") && + typeof value === "object" && + value !== null && + Object.keys(value as Record).length === 0 + ) { + // Handle at-rules with no body (e.g., @apply). + const atRuleMatch = prop.match(/@([a-zA-Z-]+)\s*(.*)/) + if (atRuleMatch) { + const [, atRuleName, atRuleParams] = atRuleMatch + const existingAtRule = atRule.nodes?.find( + (node): node is AtRule => + node.type === "atrule" && + node.name === atRuleName && + node.params === atRuleParams + ) + if (!existingAtRule) { + const newAtRule = postcss.atRule({ + name: atRuleName, + params: atRuleParams, + raws: { semicolon: true, before: "\n " }, + }) + atRule.append(newAtRule) + } + } } else if (typeof value === "object") { processRule(atRule, prop, value) } } } } else { - // Update existing utility class + // Update existing utility class. if (typeof properties === "object") { for (const [prop, value] of Object.entries(properties)) { if (typeof value === "string") { @@ -351,6 +376,31 @@ function updateCssPlugin(css: z.infer) { existingDecl ? existingDecl.replaceWith(decl) : utilityAtRule.append(decl) + } else if ( + prop.startsWith("@") && + typeof value === "object" && + value !== null && + Object.keys(value as Record).length === 0 + ) { + // Handle at-rules with no body (e.g., @apply). + const atRuleMatch = prop.match(/@([a-zA-Z-]+)\s*(.*)/) + if (atRuleMatch) { + const [, atRuleName, atRuleParams] = atRuleMatch + const existingAtRule = utilityAtRule.nodes?.find( + (node): node is AtRule => + node.type === "atrule" && + node.name === atRuleName && + node.params === atRuleParams + ) + if (!existingAtRule) { + const newAtRule = postcss.atRule({ + name: atRuleName, + params: atRuleParams, + raws: { semicolon: true, before: "\n " }, + }) + utilityAtRule.append(newAtRule) + } + } } else if (typeof value === "object") { processRule(utilityAtRule, prop, value) } diff --git a/packages/shadcn/test/utils/updaters/update-css.test.ts b/packages/shadcn/test/utils/updaters/update-css.test.ts index 3470409922..e168f0dfeb 100644 --- a/packages/shadcn/test/utils/updaters/update-css.test.ts +++ b/packages/shadcn/test/utils/updaters/update-css.test.ts @@ -929,6 +929,114 @@ describe("transformCss", () => { `) }) + test("should handle @apply inside @utility", async () => { + const input = `@import "tailwindcss";` + + const result = await transformCss(input, { + "@utility custom-btn": { + "@apply px-4 py-2 rounded-md": {}, + }, + }) + + expect(result).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @utility custom-btn { + @apply px-4 py-2 rounded-md; + }" + `) + }) + + test("should handle @apply mixed with declarations inside @utility", async () => { + const input = `@import "tailwindcss";` + + const result = await transformCss(input, { + "@utility custom-card": { + "@apply bg-white shadow-md": {}, + "border-radius": "0.5rem", + }, + }) + + expect(result).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @utility custom-card { + @apply bg-white shadow-md; + border-radius: 0.5rem; + }" + `) + }) + + test("should handle multiple @apply inside @utility", async () => { + const input = `@import "tailwindcss";` + + const result = await transformCss(input, { + "@utility custom-input": { + "@apply border border-gray-300 rounded-md": {}, + "@apply focus:ring-2 focus:ring-blue-500": {}, + padding: "0.5rem", + }, + }) + + expect(result).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @utility custom-input { + @apply border border-gray-300 rounded-md; + @apply focus:ring-2 focus:ring-blue-500; + padding: 0.5rem; + }" + `) + }) + + test("should add @apply to existing @utility", async () => { + const input = `@import "tailwindcss"; + +@utility custom-alert { + font-size: 1rem; +}` + + const result = await transformCss(input, { + "@utility custom-alert": { + "@apply font-bold text-red-500": {}, + }, + }) + + expect(result).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @utility custom-alert { + font-size: 1rem; + @apply font-bold text-red-500; + }" + `) + }) + + test("should not duplicate @apply inside existing @utility", async () => { + const input = `@import "tailwindcss"; + +@utility custom-badge { + @apply inline-flex items-center; + font-size: 0.75rem; +}` + + const result = await transformCss(input, { + "@utility custom-badge": { + "@apply inline-flex items-center": {}, + "font-size": "0.875rem", + }, + }) + + expect(result).toMatchInlineSnapshot(` + "@import "tailwindcss"; + + @utility custom-badge { + @apply inline-flex items-center; + font-size: 0.875rem; + }" + `) + }) + test("should replace existing keyframes instead of duplicating", async () => { const input = `@import "tailwindcss";