Compare commits

...

6 Commits

Author SHA1 Message Date
shadcn
4ef12b0e09 fix
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-02 09:32:24 +04:00
shadcn
801e7d6b3d style 2026-03-02 09:28:49 +04:00
shadcn
5fad0832c6 Merge branch 'main' into shadcn/fix-sidebar-demo 2026-03-02 09:20:47 +04:00
shadcn
1c73a831ae fix 2026-03-02 09:20:18 +04:00
shadcn
2b62c90dae Merge branch 'main' into gokhan/add-subpath-imports 2026-03-01 11:33:06 +04:00
Gokhan Kurt
c753e32471 feat: add registry support for subpath imports 2026-02-28 16:47:27 +03:00
16 changed files with 498 additions and 175 deletions

View File

@@ -157,7 +157,7 @@ Here are some guidelines to follow when building components for a registry.
- It is recommended to add a proper name and description to your registry item. This helps LLMs understand the component and its purpose.
- Make sure to list all registry dependencies in `registryDependencies`. A registry dependency is the name of the component in the registry eg. `input`, `button`, `card`, etc or a URL to a registry item eg. `http://localhost:3000/r/editor.json`.
- Make sure to list all dependencies in `dependencies`. A dependency is the name of the package in the registry eg. `zod`, `sonner`, etc. To set a version, you can use the `name@version` format eg. `zod@^3.20.0`.
- **Imports should always use the `@/registry` path.** eg. `import { HelloWorld } from "@/registry/new-york/hello-world/hello-world"`
- **Imports should always use the `@/registry` or `#registry` path.** eg. `import { HelloWorld } from "@/registry/new-york/hello-world/hello-world"` or `import { HelloWorld } from "#registry/new-york/hello-world/hello-world.ts"`
- Ideally, place your files within a registry item in `components`, `hooks`, `lib` directories.
## Install using the CLI

View File

@@ -209,67 +209,65 @@ function TeamSwitcher({
}
return (
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
/>
}
>
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<activeTeam.logo className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{activeTeam.name}</span>
<span className="truncate text-xs">{activeTeam.plan}</span>
</div>
<ChevronsUpDown className="ml-auto" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground text-xs">
Teams
</DropdownMenuLabel>
{teams.map((team, index) => (
<DropdownMenuItem
key={team.name}
onClick={() => setActiveTeam(team)}
className="gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-md border">
<team.logo className="size-3.5 shrink-0" />
</div>
{team.name}
<DropdownMenuShortcut>{index + 1}</DropdownMenuShortcut>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem className="gap-2 p-2">
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
<Plus className="size-4" />
</div>
<div className="text-muted-foreground font-medium">
Add team
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
/>
}
>
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<activeTeam.logo className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{activeTeam.name}</span>
<span className="truncate text-xs">{activeTeam.plan}</span>
</div>
<ChevronsUpDown className="ml-auto" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground text-xs">
Teams
</DropdownMenuLabel>
{teams.map((team, index) => (
<DropdownMenuItem
key={team.name}
onClick={() => setActiveTeam(team)}
className="gap-2 p-2"
>
<div className="flex size-6 items-center justify-center rounded-md border">
<team.logo className="size-3.5 shrink-0" />
</div>
{team.name}
<DropdownMenuShortcut>{index + 1}</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem className="gap-2 p-2">
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
<Plus className="size-4" />
</div>
<div className="text-muted-foreground font-medium">
Add team
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -395,82 +393,80 @@ function NavUser({
const { isMobile } = useSidebar()
return (
<SidebarGroup>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<BadgeCheck />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -326,36 +326,36 @@ export default function SidebarIconExample() {
key={item.title}
defaultOpen={item.isActive}
className="group/collapsible"
render={<SidebarMenuItem />}
>
<SidebarMenuButton
tooltip={item.title}
render={<CollapsibleTrigger />}
>
{item.icon}
<span>{item.title}</span>
<IconPlaceholder
lucide="ChevronRightIcon"
tabler="IconChevronRight"
hugeicons="ArrowRight01Icon"
phosphor="CaretRightIcon"
remixicon="RiArrowRightSLine"
className="ml-auto transition-transform duration-100 group-data-open/collapsible:rotate-90"
/>
</SidebarMenuButton>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
render={<a href={subItem.url} />}
>
{subItem.title}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
<SidebarMenuItem>
<CollapsibleTrigger
render={<SidebarMenuButton tooltip={item.title} />}
>
{item.icon}
<span>{item.title}</span>
<IconPlaceholder
lucide="ChevronRightIcon"
tabler="IconChevronRight"
hugeicons="ArrowRight01Icon"
phosphor="CaretRightIcon"
remixicon="RiArrowRightSLine"
className="ml-auto transition-transform duration-100 group-data-open/collapsible:rotate-90"
/>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
render={<a href={subItem.url} />}
>
{subItem.title}
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>

View File

@@ -119,9 +119,9 @@ export async function recursivelyResolveFileImports(
const moduleSpecifier = importStatement.getModuleSpecifierValue()
const isRelativeImport = moduleSpecifier.startsWith(".")
const isAliasImport = moduleSpecifier.startsWith(
`${projectInfo.aliasPrefix}/`
)
const isAliasImport =
moduleSpecifier.startsWith(`${projectInfo.aliasPrefix}/`) ||
moduleSpecifier.startsWith("#")
// If not a local import, add to the dependencies array.
if (!isAliasImport && !isRelativeImport) {

View File

@@ -1,9 +1,24 @@
import { readFileSync } from "fs"
import path from "path"
import { createMatchPath, type ConfigLoaderSuccessResult } from "tsconfig-paths"
export async function resolveImport(
importPath: string,
config: Pick<ConfigLoaderSuccessResult, "absoluteBaseUrl" | "paths">
) {
// Handle subpath imports (#) by resolving from package.json imports field.
if (importPath.startsWith("#")) {
try {
// @ts-ignore
const resolved = import.meta.resolve(importPath, import.meta.url)
if (resolved) return resolved
} catch {
// If native resolution fails, fallback to manual resolution.
}
return resolveSubpathImport(importPath, config.absoluteBaseUrl)
}
return createMatchPath(config.absoluteBaseUrl, config.paths)(
importPath,
undefined,
@@ -11,3 +26,80 @@ export async function resolveImport(
[".ts", ".tsx", ".jsx", ".js", ".css"]
)
}
function resolveSubpathImport(
importPath: string,
baseDir: string
): string | null {
let packageJson: { imports?: Record<string, unknown> }
try {
packageJson = JSON.parse(
readFileSync(path.resolve(baseDir, "package.json"), "utf-8")
)
} catch {
return null
}
const imports = packageJson.imports
if (!imports || typeof imports !== "object") {
return null
}
// Try exact match first.
if (importPath in imports) {
const resolved = resolveImportTarget(imports[importPath])
if (resolved) {
return path.resolve(baseDir, resolved)
}
return null
}
// Try wildcard patterns (most specific / longest pattern first).
const patterns = Object.keys(imports)
.filter((p) => p.includes("*"))
.sort((a, b) => b.length - a.length)
for (const pattern of patterns) {
const [prefix, suffix] = pattern.split("*")
if (
importPath.startsWith(prefix) &&
(suffix === "" || importPath.endsWith(suffix))
) {
const wildcard = suffix
? importPath.slice(prefix.length, -suffix.length)
: importPath.slice(prefix.length)
const target = resolveImportTarget(imports[pattern])
if (target) {
return path.resolve(baseDir, target.replace(/\*/g, wildcard))
}
}
}
return null
}
// Resolve a conditional import target to the first local path.
function resolveImportTarget(target: unknown): string | null {
if (typeof target === "string") {
return target.startsWith(".") ? target : null
}
if (Array.isArray(target)) {
for (const item of target) {
const resolved = resolveImportTarget(item)
if (resolved) return resolved
}
return null
}
if (target && typeof target === "object") {
// Iterate conditions in order, return the first local path.
for (const value of Object.values(target as Record<string, unknown>)) {
const resolved = resolveImportTarget(value)
if (resolved) return resolved
}
return null
}
return null
}

View File

@@ -0,0 +1,90 @@
import { describe, expect, test } from "vitest"
import { transform } from "."
import { createConfig } from "../get-config"
import { transformImport } from "./transform-import"
describe("transformImport", () => {
describe("subpath imports in source files", () => {
const testConfig = createConfig({
aliases: {
components: "@/components",
ui: "@/components/ui",
utils: "@/lib/utils",
lib: "@/lib",
hooks: "@/hooks",
},
})
test.each([
["#/registry/new-york/ui/button", "@/components/ui/button"],
["#registry/new-york/ui/button", "@/components/ui/button"],
["#/registry/new-york/hooks/use-hook", "@/hooks/use-hook"],
["#/registry/new-york/lib/helper", "@/lib/helper"],
["#/registry/new-york/components/card", "@/components/card"],
["#/lib/foo", "@/lib/foo"],
["#/lib/utils", "@/lib/utils"],
["#lib/utils", "@/lib/utils"],
["#lib/foo", "@/lib/foo"],
["#hooks/use-hook", "@/hooks/use-hook"],
["react", "react"],
])("%s → %s", async (input, expected) => {
const result = await transform(
{
filename: "test.tsx",
raw: `import x from "${input}"`,
config: testConfig,
},
[transformImport]
)
expect(result).toContain(`"${expected}"`)
})
})
describe("user config with # aliases", () => {
const hashConfig = createConfig({
aliases: {
components: "#/src/components",
ui: "#/src/components/ui",
utils: "#/src/utils",
lib: "#/src/lib",
hooks: "#/src/hooks",
},
})
test.each([
["@/registry/new-york/ui/button", "#/src/components/ui/button"],
["@/registry/new-york/hooks/use-hook", "#/src/hooks/use-hook"],
["@/registry/new-york/components/card", "#/src/components/card"],
["@/config/foo", "#/config/foo"],
["#/registry/new-york/ui/button", "#/src/components/ui/button"],
["#registry/new-york/ui/button", "#/src/components/ui/button"],
])("%s → %s", async (input, expected) => {
const result = await transform(
{
filename: "test.tsx",
raw: `import x from "${input}"`,
config: hashConfig,
},
[transformImport]
)
expect(result).toContain(`"${expected}"`)
})
// cn import requires named import to trigger utils alias rewrite.
test("@/lib/utils (cn) → #/src/utils", async () => {
const result = await transform(
{
filename: "test.tsx",
raw: `import { cn } from "@/lib/utils"`,
config: hashConfig,
},
[transformImport]
)
expect(result).toContain(`"#/src/utils"`)
})
})
})

View File

@@ -55,6 +55,13 @@ function updateImportAliases(
config: Config,
isRemote: boolean = false
) {
// Normalize subpath imports (#) to @/ prefix.
if (moduleSpecifier.startsWith("#/")) {
moduleSpecifier = moduleSpecifier.replace(/^#\//, "@/")
} else if (moduleSpecifier.startsWith("#")) {
moduleSpecifier = moduleSpecifier.replace(/^#/, "@/")
}
// Not a local import.
if (!moduleSpecifier.startsWith("@/") && !isRemote) {
return moduleSpecifier

View File

@@ -550,9 +550,11 @@ async function resolveImports(filePaths: string[], config: Config) {
const moduleSpecifier = importDeclaration.getModuleSpecifierValue()
// Filter out non-local imports.
// Also accept subpath imports (#) as local.
if (
projectInfo?.aliasPrefix &&
!moduleSpecifier.startsWith(`${projectInfo.aliasPrefix}/`)
!moduleSpecifier.startsWith(`${projectInfo.aliasPrefix}/`) &&
!moduleSpecifier.startsWith("#")
) {
continue
}
@@ -707,9 +709,14 @@ export function toAliasedImport(
rel = rel.split(path.sep).join("/") // e.g. "button/index.tsx"
// 3⃣ Strip code-file extensions, keep others (css, json, etc.)
// Subpath imports (#) require explicit file extensions.
const ext = path.posix.extname(rel)
const codeExts = [".ts", ".tsx", ".js", ".jsx"]
const keepExt = codeExts.includes(ext) ? "" : ext
const isSubpathImport =
aliasKey === "cwd"
? projectInfo.aliasPrefix?.startsWith("#")
: config.aliases[aliasKey as keyof typeof config.aliases]?.startsWith("#")
const keepExt = codeExts.includes(ext) && !isSubpathImport ? "" : ext
let noExt = rel.slice(0, rel.length - ext.length)
// 4⃣ Collapse "/index" to its directory

View File

@@ -0,0 +1,13 @@
{
"name": "test-subpath-imports",
"type": "module",
"imports": {
"#src/*": "./src/*",
"#components/*": "./src/components/*",
"#hooks": "./src/hooks/index.ts",
"#dep": {
"node": "dep-node-native",
"default": "./dep-polyfill.js"
}
}
}

View File

@@ -1,6 +1,6 @@
import path from "path"
import { loadConfig, type ConfigLoaderSuccessResult } from "tsconfig-paths"
import { expect, test } from "vitest"
import { describe, expect, test } from "vitest"
import { resolveImport } from "../../src/utils/resolve-import"
@@ -79,3 +79,41 @@ test("resolve import without base url", async () => {
path.resolve(cwd, "foo/bar")
)
})
describe("resolve subpath imports", () => {
const cwd = path.resolve(__dirname, "../fixtures/with-subpath-imports")
const config = {
absoluteBaseUrl: cwd,
paths: {},
}
test("should resolve wildcard subpath import", async () => {
expect(await resolveImport("#src/components/ui", config)).toEqual(
path.resolve(cwd, "src/components/ui")
)
})
test("should resolve more specific wildcard pattern", async () => {
expect(await resolveImport("#components/button", config)).toEqual(
path.resolve(cwd, "src/components/button")
)
})
test("should resolve exact match subpath import", async () => {
expect(await resolveImport("#hooks", config)).toEqual(
path.resolve(cwd, "src/hooks/index.ts")
)
})
test("should resolve conditional subpath import to first local path", async () => {
// #dep has { "node": "dep-node-native", "default": "./dep-polyfill.js" }
// Should skip "dep-node-native" (not local) and pick "./dep-polyfill.js"
expect(await resolveImport("#dep", config)).toEqual(
path.resolve(cwd, "dep-polyfill.js")
)
})
test("should return null for unmatched subpath import", async () => {
expect(await resolveImport("#nonexistent/foo", config)).toBeNull()
})
})

View File

@@ -1968,4 +1968,84 @@ describe("toAliasedImport", () => {
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe("@/pages/home")
})
test("should keep code extension for # subpath import alias", () => {
const filePath = "components/ui/button.tsx"
const config = {
resolvedPaths: {
cwd: "/foo/bar",
components: "/foo/bar/components",
ui: "/foo/bar/components/ui",
},
aliases: {
components: "#/src/components",
ui: "#/src/components/ui",
},
}
const projectInfo = {
aliasPrefix: "#",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe(
"#/src/components/ui/button.tsx"
)
})
test("should keep .ts extension for # subpath import alias", () => {
const filePath = "lib/utils.ts"
const config = {
resolvedPaths: {
cwd: "/foo/bar",
lib: "/foo/bar/lib",
},
aliases: {
lib: "#/src/lib",
},
}
const projectInfo = {
aliasPrefix: "#",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe(
"#/src/lib/utils.ts"
)
})
test("should still strip extension for @ alias (existing behavior)", () => {
const filePath = "components/ui/button.tsx"
const config = {
resolvedPaths: {
cwd: "/foo/bar",
components: "/foo/bar/components",
ui: "/foo/bar/components/ui",
},
aliases: {
components: "@/components",
ui: "@/components/ui",
},
}
const projectInfo = {
aliasPrefix: "@",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe(
"@/components/ui/button"
)
})
test("should keep css extension for # alias (non-code ext always kept)", () => {
const filePath = "components/styles/theme.css"
const config = {
resolvedPaths: {
cwd: "/foo/bar",
components: "/foo/bar/components",
},
aliases: {
components: "#/src/components",
},
}
const projectInfo = {
aliasPrefix: "#",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe(
"#/src/components/styles/theme.css"
)
})
})