Merge remote-tracking branch 'upstream/main' into add-gc-solid-registry

This commit is contained in:
Binnodon
2026-02-26 14:41:12 -06:00
10 changed files with 240 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": patch
---
handling of apply directive inside utility

View File

@@ -472,7 +472,7 @@ Let's make the email column sortable.
### Update `<DataTable>`
```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"

View File

@@ -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.

View File

@@ -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."
}
]

File diff suppressed because one or more lines are too long

View File

@@ -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.`

View File

@@ -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()
})

View File

@@ -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)
}

View File

@@ -317,7 +317,7 @@ function updateCssPlugin(css: z.infer<typeof registryItemCssSchema>) {
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<typeof registryItemCssSchema>) {
raws: { semicolon: true, before: "\n " },
})
atRule.append(decl)
} else if (
prop.startsWith("@") &&
typeof value === "object" &&
value !== null &&
Object.keys(value as Record<string, unknown>).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<typeof registryItemCssSchema>) {
existingDecl
? existingDecl.replaceWith(decl)
: utilityAtRule.append(decl)
} else if (
prop.startsWith("@") &&
typeof value === "object" &&
value !== null &&
Object.keys(value as Record<string, unknown>).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)
}

View File

@@ -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";