mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-12 10:21:39 +00:00
Compare commits
6 Commits
shadcn@4.2
...
shadcn/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ef12b0e09 | ||
|
|
801e7d6b3d | ||
|
|
5fad0832c6 | ||
|
|
1c73a831ae | ||
|
|
2b62c90dae | ||
|
|
c753e32471 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
13
packages/shadcn/test/fixtures/with-subpath-imports/package.json
vendored
Normal file
13
packages/shadcn/test/fixtures/with-subpath-imports/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user