Compare commits

..

26 Commits

Author SHA1 Message Date
shadcn
b1238440cb Merge branch 'main' into shadcn/package-imports 2026-04-08 02:57:19 +04:00
shadcn
6737c01997 Merge branch 'shadcn/package-imports' of github.com:shadcn-ui/ui into shadcn/package-imports 2026-04-07 18:14:53 +04:00
shadcn
77f4639edd fix 2026-04-07 18:13:52 +04:00
shadcn
421d52333e chore: changeset 2026-04-07 16:47:48 +04:00
shadcn
5002ee0e4b Merge branch 'main' into shadcn/package-imports 2026-04-07 16:44:33 +04:00
shadcn
515013c8b1 fix 2026-03-16 21:35:29 +04:00
shadcn
9145b52df0 fix
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-16 21:12:27 +04:00
shadcn
2649a1f6e4 Merge branch 'main' into shadcn/package-imports 2026-03-16 20:55:37 +04:00
shadcn
b6f3b8eaa2 fix 2026-03-16 19:48:04 +04:00
shadcn
5e69f18010 docs 2026-03-16 19:41:39 +04:00
shadcn
8297097512 fix: refactor 2026-03-16 19:21:49 +04:00
shadcn
a434fada95 fix: update test coverage 2026-03-16 17:18:05 +04:00
shadcn
0d7a005714 fix 2026-03-16 16:53:59 +04:00
shadcn
2b0dc2116a fix 2026-03-16 16:49:31 +04:00
shadcn
aaf8c0770c fix 2026-03-16 16:42:58 +04:00
shadcn
e135d1895f fix 2026-03-16 16:42:05 +04:00
shadcn
70fbec5258 fix 2026-03-16 16:40:08 +04:00
shadcn
503a895520 fix 2026-03-16 16:37:38 +04:00
shadcn
3f0fefd12b fix 2026-03-16 16:32:54 +04:00
shadcn
c96b35b66e fix 2026-03-16 16:00:51 +04:00
shadcn
08fcda032a fix 2026-03-16 15:02:15 +04:00
shadcn
04cbfb73ad fix 2026-03-16 14:58:14 +04:00
shadcn
36d0b07a0c fix 2026-03-16 14:50:18 +04:00
shadcn
83f5d46b6e fix: refactor 2026-03-16 13:48:31 +04:00
shadcn
ef35fd8f4c fix: refactor 2026-03-16 13:27:13 +04:00
shadcn
b6cfe91aa6 feat: initial commit for subpath 2026-03-16 12:56:52 +04:00
108 changed files with 4530 additions and 2814 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": minor
---
add support for package imports

View File

@@ -10,7 +10,6 @@ import { usePresetCode } from "@/app/(app)/create/hooks/use-design-system"
export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
const presetCode = usePresetCode()
const [hasCopied, setHasCopied] = React.useState(false)
const label = hasCopied ? "Copied" : `--preset ${presetCode}`
React.useEffect(() => {
if (hasCopied) {
@@ -33,13 +32,12 @@ export function CopyPreset({ className }: React.ComponentProps<typeof Button>) {
<Button
variant="outline"
onClick={handleCopy}
title={label}
className={cn(
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
className
)}
>
<span className="block min-w-0 truncate">{label}</span>
<span>{hasCopied ? "Copied" : `--preset ${presetCode}`}</span>
</Button>
)
}

View File

@@ -23,12 +23,12 @@ import { FontPicker } from "@/app/(app)/create/components/font-picker"
import { IconLibraryPicker } from "@/app/(app)/create/components/icon-library-picker"
import { MainMenu } from "@/app/(app)/create/components/main-menu"
import { MenuColorPicker } from "@/app/(app)/create/components/menu-picker"
import { OpenPreset } from "@/app/(app)/create/components/open-preset"
import { RadiusPicker } from "@/app/(app)/create/components/radius-picker"
import { RandomButton } from "@/app/(app)/create/components/random-button"
import { ResetDialog } from "@/app/(app)/create/components/reset-button"
import { StylePicker } from "@/app/(app)/create/components/style-picker"
import { ThemePicker } from "@/app/(app)/create/components/theme-picker"
import { V0Button } from "@/app/(app)/create/components/v0-button"
import { FONT_HEADING_OPTIONS, FONTS } from "@/app/(app)/create/lib/fonts"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
@@ -102,12 +102,8 @@ export function Customizer({
</FieldGroup>
</CardContent>
<CardFooter className="flex min-w-0 gap-2 md:flex-col md:rounded-b-none md:**:[button,a]:w-full">
<CopyPreset className="min-w-0 flex-1 md:flex-none" />
<OpenPreset
className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none"
label={isMobile ? "Open" : "Open Preset"}
/>
<RandomButton className="max-w-20 min-w-0 flex-1 sm:max-w-none md:flex-none" />
<CopyPreset className="flex-1 md:flex-none" />
<RandomButton className="flex-1 md:flex-none" />
<ActionMenu itemsByBase={itemsByBase} />
<ResetDialog />
</CardFooter>

View File

@@ -17,7 +17,6 @@ import {
} from "@/app/(app)/create/components/picker"
import { useActionMenuTrigger } from "@/app/(app)/create/hooks/use-action-menu"
import { useHistory } from "@/app/(app)/create/hooks/use-history"
import { useOpenPresetTrigger } from "@/app/(app)/create/hooks/use-open-preset"
import { useRandom } from "@/app/(app)/create/hooks/use-random"
import { useReset } from "@/app/(app)/create/hooks/use-reset"
import { useThemeToggle } from "@/app/(app)/create/hooks/use-theme-toggle"
@@ -28,7 +27,6 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
const [isMac, setIsMac] = React.useState(false)
const { canGoBack, canGoForward, goBack, goForward } = useHistory()
const { openActionMenu } = useActionMenuTrigger()
const { openPreset } = useOpenPresetTrigger()
const { randomize } = useRandom()
const { toggleTheme } = useThemeToggle()
const { setShowResetDialog } = useReset()
@@ -57,9 +55,6 @@ export function MainMenu({ className }: React.ComponentProps<typeof Button>) {
Navigate...
<PickerShortcut>{isMac ? "⌘P" : "Ctrl+P"}</PickerShortcut>
</PickerItem>
<PickerItem onClick={openPreset}>
Open Preset... <PickerShortcut>O</PickerShortcut>
</PickerItem>
<PickerItem onClick={randomize}>
Shuffle <PickerShortcut>R</PickerShortcut>
</PickerItem>

View File

@@ -1,200 +0,0 @@
"use client"
import * as React from "react"
import Script from "next/script"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/styles/base-nova/ui/dialog"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/styles/base-nova/ui/drawer"
import { Field, FieldContent, FieldLabel } from "@/styles/base-nova/ui/field"
import { Input } from "@/styles/base-nova/ui/input"
import {
OPEN_PRESET_FORWARD_TYPE,
useOpenPreset,
} from "@/app/(app)/create/hooks/use-open-preset"
import { parsePresetInput } from "@/app/(app)/create/lib/parse-preset-input"
import { useDesignSystemSearchParams } from "@/app/(app)/create/lib/search-params"
const PRESET_EXAMPLE = "b2D0wqNxT"
const PRESET_TITLE = "Open Preset"
const PRESET_DESCRIPTION = "Paste a preset code to load a saved configuration."
export function OpenPreset({
className,
label = "Open Preset",
}: React.ComponentProps<typeof Button> & {
label?: string
}) {
const [input, setInput] = React.useState("")
const [, setParams] = useDesignSystemSearchParams()
const isMobile = useIsMobile()
const { open, setOpen } = useOpenPreset()
const nextPreset = React.useMemo(() => parsePresetInput(input), [input])
const isInvalid = input.trim().length > 0 && nextPreset === null
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
setInput("")
}
},
[setOpen]
)
const handleSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!nextPreset) {
return
}
setParams({ preset: nextPreset })
handleOpenChange(false)
},
[handleOpenChange, nextPreset, setParams]
)
const triggerClassName = cn(
"touch-manipulation bg-transparent! px-2! py-0! text-sm! transition-none select-none hover:bg-muted! pointer-coarse:h-10!",
className
)
const desktopTrigger = (
<Button variant="outline" className={triggerClassName} />
)
const fields = (
<Field data-invalid={isInvalid || undefined}>
<FieldLabel htmlFor="preset-code" className="sr-only">
Preset code
</FieldLabel>
<FieldContent>
<Input
id="preset-code"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder={`${PRESET_EXAMPLE} or --preset ${PRESET_EXAMPLE}`}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
aria-invalid={isInvalid}
className="h-10 md:h-8"
/>
</FieldContent>
</Field>
)
if (isMobile) {
return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerTrigger asChild>
<Button variant="outline" className={triggerClassName}>
{label}
</Button>
</DrawerTrigger>
<DrawerContent className="dark rounded-t-2xl!">
<DrawerHeader>
<DrawerTitle className="text-xl">{PRESET_TITLE}</DrawerTitle>
<DrawerDescription>{PRESET_DESCRIPTION}</DrawerDescription>
</DrawerHeader>
<form onSubmit={handleSubmit}>
<div className="px-4 py-2">{fields}</div>
<DrawerFooter>
<Button type="submit" className="h-10" disabled={!nextPreset}>
Open
</Button>
<DrawerClose asChild>
<Button variant="outline" type="button" className="h-10">
Cancel
</Button>
</DrawerClose>
</DrawerFooter>
</form>
</DrawerContent>
</Drawer>
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={desktopTrigger}>{label}</DialogTrigger>
<DialogContent className="dark">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{PRESET_TITLE}</DialogTitle>
<DialogDescription>{PRESET_DESCRIPTION}</DialogDescription>
</DialogHeader>
<div className="py-4">{fields}</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" type="button" />}>
Cancel
</DialogClose>
<Button type="submit" disabled={!nextPreset}>
Open
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
export function OpenPresetScript() {
return (
<Script
id="open-preset-listener"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `
(function() {
// Forward O key.
document.addEventListener('keydown', function(e) {
if (e.key === 'o' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (
(e.target instanceof HTMLElement && e.target.isContentEditable) ||
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLSelectElement
) {
return;
}
e.preventDefault();
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: '${OPEN_PRESET_FORWARD_TYPE}',
key: e.key
}, '*');
}
}
});
})();
`,
}}
/>
)
}

View File

@@ -11,7 +11,6 @@ import { DARK_MODE_FORWARD_TYPE } from "@/app/(app)/create/components/mode-switc
import { PreviewSwitcher } from "@/app/(app)/create/components/preview-switcher"
import { RANDOMIZE_FORWARD_TYPE } from "@/app/(app)/create/components/random-button"
import { sendToIframe } from "@/app/(app)/create/hooks/use-iframe-sync"
import { OPEN_PRESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-open-preset"
import { RESET_FORWARD_TYPE } from "@/app/(app)/create/hooks/use-reset"
import {
serializeDesignSystemSearchParams,
@@ -21,6 +20,78 @@ import {
// Hoisted — avoids recreating on every message event. (js-hoist-regexp)
const MAC_REGEX = /Mac|iPhone|iPad|iPod/
// Hoisted — only uses module-level constants, no component state. (rendering-hoist-jsx)
function handleMessage(event: MessageEvent) {
if (
typeof window === "undefined" ||
event.origin !== window.location.origin
) {
return
}
const type = event.data.type
if (type === CMD_K_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "k",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RANDOMIZE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "r",
bubbles: true,
cancelable: true,
})
)
} else if (type === UNDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === REDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
shiftKey: true,
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "R",
shiftKey: true,
bubbles: true,
cancelable: true,
})
)
} else if (type === DARK_MODE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "d",
bubbles: true,
cancelable: true,
})
)
}
}
export function Preview() {
const [params] = useDesignSystemSearchParams()
const iframeRef = React.useRef<HTMLIFrameElement>(null)
@@ -46,89 +117,6 @@ export function Preview() {
}, [params])
React.useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const iframeWindow = iframeRef.current?.contentWindow
if (
!iframeWindow ||
event.origin !== window.location.origin ||
event.source !== iframeWindow ||
!event.data ||
typeof event.data !== "object"
) {
return
}
const type = event.data.type
if (type === CMD_K_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "k",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RANDOMIZE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "r",
bubbles: true,
cancelable: true,
})
)
} else if (type === OPEN_PRESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "o",
bubbles: true,
cancelable: true,
})
)
} else if (type === UNDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === REDO_FORWARD_TYPE) {
const isMac = MAC_REGEX.test(navigator.userAgent)
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "z",
shiftKey: true,
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
cancelable: true,
})
)
} else if (type === RESET_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: "R",
shiftKey: true,
bubbles: true,
cancelable: true,
})
)
} else if (type === DARK_MODE_FORWARD_TYPE) {
document.dispatchEvent(
new KeyboardEvent("keydown", {
key: event.data.key || "d",
bubbles: true,
cancelable: true,
})
)
}
}
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)

View File

@@ -28,7 +28,7 @@ export function RandomButton({
)}
{...props}
>
<span className="w-full truncate text-center font-medium">Shuffle</span>
<span className="w-full text-center font-medium">Shuffle</span>
</Button>
)
}

View File

@@ -1,81 +0,0 @@
"use client"
import * as React from "react"
import useSWR from "swr"
const OPEN_PRESET_KEY = "create:open-preset-open"
export const OPEN_PRESET_FORWARD_TYPE = "open-preset-forward"
function isEditableTarget(target: EventTarget | null) {
return (
(target instanceof HTMLElement && target.isContentEditable) ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
)
}
export function useOpenPreset() {
const { data: open = false, mutate: setOpenData } = useSWR<boolean>(
OPEN_PRESET_KEY,
{
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
}
)
const handleOpenChange = React.useCallback(
(nextOpen: boolean) => {
void setOpenData(nextOpen, { revalidate: false })
},
[setOpenData]
)
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (
e.key === "o" &&
!e.shiftKey &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey
) {
if (isEditableTarget(e.target)) {
return
}
e.preventDefault()
void setOpenData(true, { revalidate: false })
}
}
document.addEventListener("keydown", down)
return () => {
document.removeEventListener("keydown", down)
}
}, [setOpenData])
return {
open,
setOpen: handleOpenChange,
}
}
export function useOpenPresetTrigger() {
const { mutate: setOpenData } = useSWR<boolean>(OPEN_PRESET_KEY, {
fallbackData: false,
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: false,
})
const openPreset = React.useCallback(() => {
void setOpenData(true, { revalidate: false })
}, [setOpenData])
return {
openPreset,
}
}

View File

@@ -1,17 +0,0 @@
import { describe, expect, it } from "vitest"
import { parsePresetInput } from "./parse-preset-input"
describe("parsePresetInput", () => {
it("accepts a raw preset code", () => {
expect(parsePresetInput("b0")).toBe("b0")
})
it("accepts a --preset flag", () => {
expect(parsePresetInput(" --preset b0 ")).toBe("b0")
})
it("rejects invalid preset input", () => {
expect(parsePresetInput("open sesame")).toBeNull()
})
})

View File

@@ -1,15 +0,0 @@
import { isPresetCode } from "shadcn/preset"
const PRESET_FLAG_PATTERN = /^--preset\b\s+(.+)$/i
export function parsePresetInput(value: string) {
const input = value.trim()
if (!input) {
return null
}
const preset = input.match(PRESET_FLAG_PATTERN)?.[1]?.trim() ?? input
return isPresetCode(preset) ? preset : null
}

View File

@@ -4,7 +4,6 @@ import { mdxComponents } from "@/mdx-components"
import { IconArrowLeft, IconArrowRight } from "@tabler/icons-react"
import { findNeighbour } from "fumadocs-core/page-tree"
import { replaceComponentsList } from "@/lib/llm"
import { source } from "@/lib/source"
import { absoluteUrl } from "@/lib/utils"
import { DocsBaseSwitcher } from "@/components/docs-base-switcher"
@@ -84,7 +83,7 @@ export default async function Page(props: {
const neighbours = isChangelog
? { previous: null, next: null }
: findNeighbour(source.pageTree, page.url)
const raw = replaceComponentsList(await page.data.getText("raw"))
const raw = await page.data.getText("raw")
return (
<div

View File

@@ -10,7 +10,6 @@ import { ActionMenuScript } from "@/app/(app)/create/components/action-menu"
import { DesignSystemProvider } from "@/app/(app)/create/components/design-system-provider"
import { HistoryScript } from "@/app/(app)/create/components/history-buttons"
import { DarkModeScript } from "@/app/(app)/create/components/mode-switcher"
import { OpenPresetScript } from "@/app/(app)/create/components/open-preset"
import { PreviewStyle } from "@/app/(app)/create/components/preview-style"
import { RandomizeScript } from "@/app/(app)/create/components/random-button"
import {
@@ -140,7 +139,6 @@ export default async function BlockPage({
<PreventScrollOnFocusScript />
<PreviewStyle />
<ActionMenuScript />
<OpenPresetScript />
<RandomizeScript />
<HistoryScript />
<DarkModeScript />

View File

@@ -6,7 +6,7 @@ import { IconCheck, IconCopy, IconPlus } from "@tabler/icons-react"
import { useConfig } from "@/hooks/use-config"
import { useIsMobile } from "@/hooks/use-mobile"
import { copyToClipboardWithMeta } from "@/components/copy-button"
import { Button } from "@/styles/base-nova/ui/button"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Dialog,
DialogClose,
@@ -15,7 +15,8 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/styles/base-nova/ui/dialog"
DialogTrigger,
} from "@/registry/new-york-v4/ui/dialog"
import {
Drawer,
DrawerClose,
@@ -24,74 +25,42 @@ import {
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/styles/base-nova/ui/drawer"
DrawerTrigger,
} from "@/registry/new-york-v4/ui/drawer"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/styles/base-nova/ui/tabs"
} from "@/registry/new-york-v4/ui/tabs"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/styles/base-nova/ui/tooltip"
const DirectoryAddContext = React.createContext<{
open: (registry: { name: string }) => void
}>({
open: () => {},
})
export function useDirectoryAdd() {
return React.useContext(DirectoryAddContext)
}
} from "@/registry/new-york-v4/ui/tooltip"
export function DirectoryAddButton({
registry,
}: {
registry: { name: string }
}) {
const { open } = useDirectoryAdd()
return (
<Button
size="sm"
variant="outline"
className="relative z-10"
onClick={() => open(registry)}
>
Add <IconPlus />
</Button>
)
}
export function DirectoryAddProvider({
children,
}: {
children: React.ReactNode
}) {
const [config, setConfig] = useConfig()
const [hasCopied, setHasCopied] = React.useState(false)
const [isOpen, setIsOpen] = React.useState(false)
const [selectedRegistry, setSelectedRegistry] = React.useState<{
name: string
} | null>(null)
const [open, setOpen] = React.useState(false)
const isMobile = useIsMobile()
const packageManager = config.packageManager || "pnpm"
const commands = React.useMemo(() => {
if (!selectedRegistry) return null
return {
pnpm: `pnpm dlx shadcn@latest registry add ${selectedRegistry.name}`,
npm: `npx shadcn@latest registry add ${selectedRegistry.name}`,
yarn: `yarn dlx shadcn@latest registry add ${selectedRegistry.name}`,
bun: `bunx --bun shadcn@latest registry add ${selectedRegistry.name}`,
pnpm: `pnpm dlx shadcn@latest registry add ${registry.name}`,
npm: `npx shadcn@latest registry add ${registry.name}`,
yarn: `yarn dlx shadcn@latest registry add ${registry.name}`,
bun: `bunx --bun shadcn@latest registry add ${registry.name}`,
}
}, [selectedRegistry])
}, [registry.name])
const command = commands?.[packageManager] ?? ""
const command = commands[packageManager]
React.useEffect(() => {
if (hasCopied) {
@@ -105,23 +74,19 @@ export function DirectoryAddProvider({
name: "copy_registry_add_command",
properties: {
command,
registry: selectedRegistry?.name ?? "",
registry: registry.name,
},
})
setHasCopied(true)
}, [command, selectedRegistry?.name])
}, [command, registry.name])
const contextValue = React.useMemo(
() => ({
open: (registry: { name: string }) => {
setSelectedRegistry(registry)
setIsOpen(true)
},
}),
[]
const Trigger = (
<Button size="sm" variant="outline" className="relative z-10">
Add <IconPlus />
</Button>
)
const Content = commands ? (
const Content = (
<Tabs
value={packageManager}
onValueChange={(value) => {
@@ -130,28 +95,30 @@ export function DirectoryAddProvider({
packageManager: value as "pnpm" | "npm" | "yarn" | "bun",
})
}}
className="gap-0 overflow-hidden rounded-xl border"
className="gap-0 overflow-hidden rounded-lg border"
>
<div className="flex items-center gap-2 border-b p-1.5">
<TabsList className="h-auto *:data-[slot=tabs-trigger]:pt-0">
<div className="flex items-center gap-2 border-b p-2">
<TabsList className="h-auto rounded-none bg-transparent p-0 font-mono *:data-[slot=tabs-trigger]:border *:data-[slot=tabs-trigger]:border-transparent *:data-[slot=tabs-trigger]:pt-0.5 *:data-[slot=tabs-trigger]:shadow-none! *:data-[slot=tabs-trigger]:data-[state=active]:border-input">
<TabsTrigger value="pnpm">pnpm</TabsTrigger>
<TabsTrigger value="npm">npm</TabsTrigger>
<TabsTrigger value="yarn">yarn</TabsTrigger>
<TabsTrigger value="bun">bun</TabsTrigger>
</TabsList>
<Tooltip>
<TooltipTrigger
render={
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopy}
/>
}
>
{hasCopied ? <IconCheck /> : <IconCopy />}
<span className="sr-only">Copy command</span>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
className="ml-auto size-7 rounded-lg"
onClick={handleCopy}
>
{hasCopied ? (
<IconCheck className="size-4" />
) : (
<IconCopy className="size-4" />
)}
<span className="sr-only">Copy command</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{hasCopied ? "Copied!" : "Copy command"}
@@ -168,48 +135,47 @@ export function DirectoryAddProvider({
</TabsContent>
))}
</Tabs>
) : null
)
if (isMobile) {
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{Trigger}</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Add Registry</DrawerTitle>
<DrawerDescription>
Run this command to add {registry.name} to your project.
</DrawerDescription>
</DrawerHeader>
<div className="px-4">{Content}</div>
<DrawerFooter>
<DrawerClose asChild>
<Button size="sm">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
return (
<DirectoryAddContext value={contextValue}>
{children}
{isMobile ? (
<Drawer open={isOpen} onOpenChange={setIsOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Add Registry</DrawerTitle>
<DrawerDescription>
Run this command to add {selectedRegistry?.name} to your
project.
</DrawerDescription>
</DrawerHeader>
<div className="px-4">{Content}</div>
<DrawerFooter>
<DrawerClose asChild>
<Button size="sm">Done</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
) : (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="animate-none! sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Registry</DialogTitle>
<DialogDescription>
Run this command to add {selectedRegistry?.name} to your
project.
</DialogDescription>
</DialogHeader>
{Content}
<DialogFooter>
<DialogClose render={<Button size="sm" variant="outline" />}>
Done
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</DirectoryAddContext>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{Trigger}</DialogTrigger>
<DialogContent className="dialog-ring animate-none! rounded-xl sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Registry</DialogTitle>
<DialogDescription>
Run this command to add {registry.name} to your project.
</DialogDescription>
</DialogHeader>
{Content}
<DialogFooter>
<DialogClose asChild>
<Button size="sm">Done</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,20 +1,12 @@
"use client"
import * as React from "react"
import { usePathname } from "next/navigation"
import {
IconArrowUpRight,
IconChevronLeft,
IconChevronRight,
} from "@tabler/icons-react"
import { IconArrowUpRight } from "@tabler/icons-react"
import { cn } from "@/lib/utils"
import { useSearchRegistry } from "@/hooks/use-search-registry"
import {
DirectoryAddButton,
DirectoryAddProvider,
} from "@/components/directory-add-button"
import { Button, buttonVariants } from "@/styles/base-nova/ui/button"
import { DirectoryAddButton } from "@/components/directory-add-button"
import globalRegistries from "@/registry/directory.json"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
Item,
ItemActions,
@@ -25,16 +17,9 @@ import {
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@/styles/base-nova/ui/item"
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
} from "@/styles/base-nova/ui/pagination"
import { Skeleton } from "@/styles/base-nova/ui/skeleton"
} from "@/registry/new-york-v4/ui/item"
import { SearchDirectory } from "./directory-search"
import { SearchDirectory } from "./search-directory"
function getHomepageUrl(homepage: string) {
const url = new URL(homepage)
@@ -44,308 +29,55 @@ function getHomepageUrl(homepage: string) {
return url.toString()
}
function getPageHref(pathname: string, query: string, page: number) {
const searchParams = new URLSearchParams()
if (query) {
searchParams.set("q", query)
}
if (page > 1) {
searchParams.set("page", page.toString())
}
const search = searchParams.toString()
return search ? `${pathname}?${search}` : pathname
}
function getPageNumbers(current: number, total: number) {
if (total <= 7) {
return Array.from({ length: total }, (_, i) => i + 1) as (
| number
| "ellipsis"
)[]
}
const pages: (number | "ellipsis")[] = [1]
// Show ellipsis or page 2 directly if only one number would be hidden.
if (current > 4) {
pages.push("ellipsis")
} else if (current >= 4) {
pages.push(2)
}
const start = Math.max(2, current - 1)
const end = Math.min(total - 1, current + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
// Show ellipsis or second-to-last page directly if only one number would be hidden.
if (current < total - 3) {
pages.push("ellipsis")
} else if (current <= total - 3) {
pages.push(total - 1)
}
pages.push(total)
return pages
}
type DirectoryPaginationLinkProps = React.ComponentProps<"a"> & {
isActive?: boolean
size?: React.ComponentProps<typeof Button>["size"]
}
function DirectoryPaginationLink({
className,
isActive,
size = "icon",
...props
}: DirectoryPaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function DirectoryPaginationPrevious({
className,
text = "Previous",
...props
}: DirectoryPaginationLinkProps & { text?: string }) {
return (
<DirectoryPaginationLink
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<IconChevronLeft className="cn-rtl-flip size-4" />
<span className="hidden sm:block">{text}</span>
</DirectoryPaginationLink>
)
}
function DirectoryPaginationNext({
className,
text = "Next",
...props
}: DirectoryPaginationLinkProps & { text?: string }) {
return (
<DirectoryPaginationLink
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<IconChevronRight className="cn-rtl-flip size-4" />
</DirectoryPaginationLink>
)
}
export function DirectoryList() {
const pathname = usePathname()
const {
isLoading,
paginatedRegistries,
page,
query,
registries,
totalPages,
setPage,
setQuery,
} = useSearchRegistry()
const previousHref =
page > 1 ? getPageHref(pathname, query, page - 1) : undefined
const nextHref =
page < totalPages ? getPageHref(pathname, query, page + 1) : undefined
const handlePageChange = React.useCallback(
(
event: React.MouseEvent<HTMLAnchorElement>,
targetPage: number,
disabled = false
) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return
}
if (disabled || targetPage === page) {
event.preventDefault()
return
}
event.preventDefault()
void setPage(targetPage)
},
[page, setPage]
)
const { registries } = useSearchRegistry()
return (
<DirectoryAddProvider>
<div className="mt-6">
{isLoading ? (
<DirectoryListSkeleton />
) : (
<>
<SearchDirectory
query={query}
registriesCount={registries.length}
setQuery={setQuery}
/>
<ItemGroup className="my-8">
{paginatedRegistries.map((registry, index) => (
<React.Fragment key={registry.name}>
<Item className="group/item relative gap-6 px-0">
<ItemMedia
variant="image"
dangerouslySetInnerHTML={{ __html: registry.logo }}
className="grayscale *:[svg]:size-8 *:[svg]:fill-foreground"
/>
<ItemContent>
<ItemTitle>
<a
href={getHomepageUrl(registry.homepage)}
target="_blank"
rel="noopener noreferrer external"
className="group flex items-center gap-1"
>
{registry.name}{" "}
<IconArrowUpRight className="size-4 opacity-0 group-hover:opacity-100" />
</a>
</ItemTitle>
{registry.description && (
<ItemDescription className="text-pretty">
{registry.description}
</ItemDescription>
)}
</ItemContent>
<ItemActions className="relative z-10 hidden self-start sm:flex">
<DirectoryAddButton registry={registry} />
</ItemActions>
<ItemFooter className="justify-start pl-16 sm:hidden">
<Button size="sm" variant="outline">
View <IconArrowUpRight />
</Button>
<DirectoryAddButton registry={registry} />
</ItemFooter>
</Item>
{index < paginatedRegistries.length - 1 && (
<ItemSeparator className="my-1" />
)}
</React.Fragment>
))}
</ItemGroup>
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<DirectoryPaginationPrevious
href={previousHref}
aria-disabled={page <= 1 || undefined}
tabIndex={page <= 1 ? -1 : undefined}
onClick={(event) =>
handlePageChange(event, page - 1, page <= 1)
}
className={cn(
page <= 1
? "pointer-events-none opacity-50"
: "cursor-pointer"
)}
/>
</PaginationItem>
{getPageNumbers(page, totalPages).map((p, i) =>
p === "ellipsis" ? (
<PaginationItem key={`ellipsis-${i}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={p}>
<DirectoryPaginationLink
href={getPageHref(pathname, query, p)}
isActive={p === page}
onClick={(event) => handlePageChange(event, p)}
className="cursor-pointer"
>
{p}
</DirectoryPaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<DirectoryPaginationNext
href={nextHref}
aria-disabled={page >= totalPages || undefined}
tabIndex={page >= totalPages ? -1 : undefined}
onClick={(event) =>
handlePageChange(event, page + 1, page >= totalPages)
}
className={cn(
page >= totalPages
? "pointer-events-none opacity-50"
: "cursor-pointer"
)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</>
)}
</div>
</DirectoryAddProvider>
)
}
function DirectoryListSkeleton() {
return (
<>
<Skeleton className="h-8 w-full rounded-lg" />
<div className="mt-6">
<SearchDirectory />
<ItemGroup className="my-8">
{Array.from({ length: 10 }, (_, index) => (
{registries.map((registry, index) => (
<React.Fragment key={index}>
<Item className="relative items-start gap-6 px-0">
<Skeleton className="size-8 rounded-lg" />
<Item className="group/item relative gap-6 px-0">
<ItemMedia
variant="image"
dangerouslySetInnerHTML={{ __html: registry.logo }}
className="grayscale *:[svg]:size-8 *:[svg]:fill-foreground"
/>
<ItemContent>
<Skeleton className="h-4 w-32 sm:w-40" />
<Skeleton className="mt-1.5 h-4 w-full max-w-md" />
<Skeleton className="mt-1 h-4 w-3/4 max-w-sm" />
<ItemTitle>
<a
href={getHomepageUrl(registry.homepage)}
target="_blank"
rel="noopener noreferrer external"
className="group flex items-center gap-1"
>
{registry.name}{" "}
<IconArrowUpRight className="size-4 opacity-0 group-hover:opacity-100" />
</a>
</ItemTitle>
{registry.description && (
<ItemDescription className="text-pretty">
{registry.description}
</ItemDescription>
)}
</ItemContent>
<ItemActions className="hidden self-start sm:flex">
<Skeleton className="h-7 w-16 rounded-lg" />
<ItemActions className="relative z-10 hidden self-start sm:flex">
<DirectoryAddButton registry={registry} />
</ItemActions>
<ItemFooter className="justify-start gap-2 pl-16 sm:hidden">
<Skeleton className="h-9 w-20 rounded-lg" />
<Skeleton className="h-9 w-24 rounded-lg" />
<ItemFooter className="justify-start pl-16 sm:hidden">
<Button size="sm" variant="outline">
View <IconArrowUpRight />
</Button>
<DirectoryAddButton registry={registry} />
</ItemFooter>
</Item>
{index < 9 && <ItemSeparator className="my-1" />}
{index < globalRegistries.length - 1 && (
<ItemSeparator className="my-1" />
)}
</React.Fragment>
))}
</ItemGroup>
</>
</div>
)
}

View File

@@ -1,24 +1,23 @@
"use client"
import * as React from "react"
import { Search, X } from "lucide-react"
import { Field } from "@/styles/base-nova/ui/field"
import { useSearchRegistry } from "@/hooks/use-search-registry"
import { Field } from "@/registry/new-york-v4/ui/field"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/styles/base-nova/ui/input-group"
} from "@/registry/new-york-v4/ui/input-group"
export const SearchDirectory = () => {
const { query, registries, setQuery } = useSearchRegistry()
const onQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setQuery(value)
}
export function SearchDirectory({
query,
registriesCount,
setQuery,
}: {
query: string
registriesCount: number
setQuery: (value: string | null) => void
}) {
return (
<Field>
<InputGroup>
@@ -26,15 +25,14 @@ export function SearchDirectory({
<Search />
</InputGroupAddon>
<InputGroupInput
className="h-full"
placeholder="Search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={onQueryChange}
/>
<InputGroupAddon align="inline-end">
<span className="text-muted-foreground tabular-nums sm:text-xs">
{registriesCount}{" "}
{registriesCount === 1 ? "registry" : "registries"}
{registries.length}{" "}
{registries.length === 1 ? "registry" : "registries"}
</span>
</InputGroupAddon>
<InputGroupAddon

View File

@@ -140,15 +140,87 @@ Setting this option to `false` allows components to be added as JavaScript with
## aliases
The CLI uses these values and the `paths` config from your `tsconfig.json` or `jsconfig.json` file to place generated components in the correct location.
The CLI uses these values to place generated components in the correct location and rewrite imports.
Path aliases have to be set up in your `tsconfig.json` or `jsconfig.json` file.
You can back these aliases with either:
1. `compilerOptions.paths` in your `tsconfig.json` or `jsconfig.json`
2. `package.json#imports` with TypeScript package import resolution enabled
The aliases in `components.json` are still required when using the CLI. They tell the CLI which import roots map to `components`, `ui`, `lib`, `hooks`, and `utils`.
<Callout className="mt-6">
**Important:** If you're using the `src` directory, make sure it is included
under `paths` in your `tsconfig.json` or `jsconfig.json` file.
**Important:** If you're using package imports, enable
`resolvePackageJsonImports` and use `moduleResolution: "bundler"` in your
`tsconfig.json`. If you're using `paths`, make sure your aliases include the
`src` directory when applicable.
</Callout>
### Using `tsconfig` or `jsconfig` paths
```json title="tsconfig.json"
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
```
### Using `package.json#imports`
Recommended setup for a single-package app:
```json title="package.json"
{
"imports": {
"#components/*": "./src/components/*",
"#lib/*": "./src/lib/*",
"#hooks/*": "./src/hooks/*"
}
}
```
```json title="tsconfig.json"
{
"compilerOptions": {
"moduleResolution": "bundler",
"resolvePackageJsonImports": true
}
}
```
```json title="components.json"
{
"aliases": {
"components": "#components",
"ui": "#components/ui",
"lib": "#lib",
"hooks": "#hooks",
"utils": "#lib/utils"
}
}
```
The aliases in `components.json` still tell the CLI where to place
`components`, `ui`, `lib`, `hooks`, and `utils`. `package.json#imports`
provides the runtime and TypeScript resolution for those `#...` specifiers.
The matched `imports` target also controls whether generated `#...` imports keep
file extensions:
- `"#components/*": "./src/components/*"` can generate imports like
`#components/button.tsx`
- `"#components/*": "./src/components/*.tsx"` generates imports like
`#components/button`
For monorepos, see the <Link href="/docs/monorepo">monorepo docs</Link>. Local
workspace aliases can use `package.json#imports`, while shared workspace
imports such as `@workspace/ui/components` are resolved from the target
package's `exports`.
### aliases.utils
Import alias for your utility functions.

View File

@@ -3,9 +3,14 @@ title: Registry Directory
description: Discover community registries for shadcn/ui components and blocks.
---
import { TriangleAlertIcon } from "lucide-react"
These registries are built into the CLI with no additional configuration required. To add a component, run: `npx shadcn add @<registry>/<component>`.
<Callout className="bg-muted font-semibold">
<Callout
type="warning"
className="border-amber-200 bg-amber-50 font-semibold dark:border-amber-900 dark:bg-amber-950"
>
Community registries are maintained by third-party developers. Always review
code on installation to ensure it meets your security and quality standards.
</Callout>

View File

@@ -164,3 +164,85 @@ turbo.json
4. **For Tailwind CSS v4, leave the `tailwind` config empty in the `components.json` file.**
By following these requirements, the CLI will be able to install ui components, blocks, libs and hooks to the correct paths and handle imports for you.
<Callout className="mt-6">
`package.json#imports` works well for package-local aliases inside a
workspace, for example inside `packages/ui`. For shared workspace imports such
as `@workspace/ui/components`, keep explicit aliases in `components.json`. The
CLI uses those aliases to route files across workspace boundaries.
</Callout>
## Using `package.json#imports`
For a monorepo that uses package imports and does not rely on
`tsconfig.json` `paths`, use:
- local `#...` aliases for files inside each workspace
- workspace package `exports` for shared imports such as
`@workspace/ui/components`
For example, an app workspace can use local package imports:
```json showLineNumbers title="apps/web/package.json"
{
"name": "web",
"private": true,
"type": "module",
"imports": {
"#*": "./src/*"
},
"dependencies": {
"@workspace/ui": "workspace:*"
}
}
```
```json showLineNumbers title="apps/web/components.json"
{
"aliases": {
"components": "#components",
"ui": "@workspace/ui/components",
"lib": "#lib",
"hooks": "#hooks",
"utils": "@workspace/ui/lib/utils"
}
}
```
And the shared UI package can expose its install targets with `exports`:
```json showLineNumbers title="packages/ui/package.json"
{
"name": "@workspace/ui",
"private": true,
"type": "module",
"imports": {
"#*": "./src/*"
},
"exports": {
"./globals.css": "./src/styles/globals.css",
"./components/*": "./src/components/*.tsx",
"./lib/*": "./src/lib/*.ts",
"./hooks/*": "./src/hooks/*.ts"
}
}
```
```json showLineNumbers title="packages/ui/components.json"
{
"aliases": {
"components": "#components",
"ui": "#components",
"lib": "#lib",
"hooks": "#hooks",
"utils": "#lib/utils"
}
}
```
In this setup:
- files added from the app to the shared UI package are routed through
`@workspace/ui/...`
- files added inside `packages/ui` use the package-local `#...` aliases
- the shared package must export any path referenced by another workspace

View File

@@ -1,21 +0,0 @@
---
title: April 2026 - shadcn apply
description: Switch presets in existing projects without starting over.
date: 2026-04-08
---
We added `shadcn apply` so you can switch presets in an existing project without starting over.
When you run `npx shadcn@latest apply` in an existing project, we apply a new preset, reinstall your existing components, and update your theme, colors, CSS variables, fonts, and icons.
```bash
npx shadcn@latest apply --preset b2D0vQ7G4
```
The CLI keeps the current base and RTL settings from your existing project, even when the preset URL was generated with different values.
<Button asChild size="sm">
<Link href="/create" className="mt-6 no-underline!">
Try a Preset
</Link>
</Button>

View File

@@ -19,9 +19,11 @@ Add the following dependencies to your project:
npm install shadcn class-variance-authority clsx tailwind-merge lucide-react tw-animate-css
```
### Configure path aliases
### Configure import aliases
Configure the path aliases in your `tsconfig.json` file.
Choose one of the following alias setups.
#### Option A: `tsconfig.json` paths
```json {3-6} title="tsconfig.json" showLineNumbers
{
@@ -34,7 +36,31 @@ Configure the path aliases in your `tsconfig.json` file.
}
```
The `@` alias is a preference. You can use other aliases if you want.
#### Option B: `package.json#imports`
```json title="package.json" showLineNumbers
{
"imports": {
"#components/*": "./src/components/*",
"#lib/*": "./src/lib/*",
"#hooks/*": "./src/hooks/*"
}
}
```
```json title="tsconfig.json" showLineNumbers
{
"compilerOptions": {
"moduleResolution": "bundler",
"resolvePackageJsonImports": true
}
}
```
The `@` alias is a preference. You can use other aliases if you want. If you
use `package.json#imports`, keep the matching alias roots in `components.json`.
See the <Link href="/docs/components-json">components.json docs</Link> for the
matching aliases and package imports behavior.
### Configure styles
@@ -211,6 +237,20 @@ Create a `components.json` file in the root of your project.
}
```
If you're using `package.json#imports`, use the corresponding `#...` aliases instead:
```json title="components.json" showLineNumbers
{
"aliases": {
"components": "#components",
"utils": "#lib/utils",
"ui": "#components/ui",
"lib": "#lib",
"hooks": "#hooks"
}
}
```
### That's it
You can now start adding components to your project.

View File

@@ -1,10 +1,7 @@
import { debounce, parseAsInteger, useQueryState } from "nuqs"
import { debounce, useQueryState } from "nuqs"
import { useMounted } from "@/hooks/use-mounted"
import globalRegistries from "@/registry/directory.json"
const PAGE_SIZE = 10
const normalizeQuery = (query: string) =>
query.toLowerCase().replaceAll(" ", "").replaceAll("@", "")
@@ -28,44 +25,15 @@ const searchDirectory = (query: string | null) => {
return globalRegistries.filter((registry) => finderFn(registry, query))
}
export function useSearchRegistry() {
const mounted = useMounted()
export const useSearchRegistry = () => {
const [query, setQuery] = useQueryState("q", {
defaultValue: "",
limitUrlUpdates: debounce(250),
})
const [page, setPage] = useQueryState("page", {
...parseAsInteger,
defaultValue: 1,
history: "push",
})
const currentQuery = mounted ? query : ""
const currentPageValue = mounted ? page : 1
const registries = searchDirectory(currentQuery)
const totalPages = Math.ceil(registries.length / PAGE_SIZE)
// Clamp page to valid range.
const currentPage = Math.max(1, Math.min(currentPageValue, totalPages))
const paginatedRegistries = registries.slice(
(currentPage - 1) * PAGE_SIZE,
currentPage * PAGE_SIZE
)
return {
isLoading: !mounted,
query: currentQuery,
setQuery: (value: string | null) => {
setQuery(value)
setPage(null)
},
registries,
paginatedRegistries,
page: currentPage,
totalPages,
setPage,
query,
registries: searchDirectory(query),
setQuery,
}
}

View File

@@ -1,9 +1,7 @@
import fs from "fs"
import { ExamplesIndex } from "@/examples/__index__"
import { getPagesFromFolder, type PageTreeFolder } from "@/lib/page-tree"
import { source } from "@/lib/source"
import { absoluteUrl } from "@/lib/utils"
import { Index as StylesIndex } from "@/registry/__index__"
import { type Style } from "@/registry/_legacy-styles"
import { BASES } from "@/registry/bases"
@@ -37,28 +35,28 @@ function getRegistryEntry(name: string, styleName: string) {
)
}
export function replaceComponentsList(content: string) {
const componentsFolder = source.pageTree.children.find(
function getComponentsList() {
const components = source.pageTree.children.find(
(page) => page.$id === "components"
)
const list =
componentsFolder?.type === "folder"
? getPagesFromFolder(componentsFolder as PageTreeFolder, "radix")
.map((component) => {
const slug = component.url.replace(/^\/docs\//, "").split("/")
const description = source.getPage(slug)?.data.description?.trim()
const url = absoluteUrl(component.url.replace("/radix/", "/"))
return `- [${component.name}](${url})${
description ? `: ${description}` : ""
}`
})
.join("\n")
: ""
return content.replace(/<ComponentsList\s*\/>/g, list)
if (components?.type !== "folder") {
return ""
}
const list = components.children.filter(
(component) => component.type === "page"
)
return list
.map((component) => `- [${component.name}](${component.url})`)
.join("\n")
}
export function processMdxForLLMs(content: string, style: Style["name"]) {
content = replaceComponentsList(content)
// Replace <ComponentsList /> with a markdown list of components.
const componentsListRegex = /<ComponentsList\s*\/>/g
content = content.replace(componentsListRegex, getComponentsList())
const componentPreviewRegex =
/<ComponentPreview[\s\S]*?name="([^"]+)"[\s\S]*?\/>/g

View File

@@ -9,8 +9,7 @@
- [components.json](https://ui.shadcn.com/docs/components-json): Configuration file for customizing the CLI and component installation.
- [Theming](https://ui.shadcn.com/docs/theming): Guide to customizing colors, typography, and design tokens.
- [Changelog](https://ui.shadcn.com/docs/changelog): Release notes and version history.
- [Skills](https://ui.shadcn.com/docs/skills): Deep shadcn/ui knowledge for AI assistants like Claude Code.
- [Directory](https://ui.shadcn.com/docs/directory): Community registries built into the CLI.
- [About](https://ui.shadcn.com/docs/about): Credits and project information.
## Installation
@@ -29,6 +28,7 @@
### Form & Input
- [Form](https://ui.shadcn.com/docs/components/form): Building forms with React Hook Form and Zod validation.
- [Field](https://ui.shadcn.com/docs/components/field): Field component for form inputs with labels and error messages.
- [Button](https://ui.shadcn.com/docs/components/button): Button component with multiple variants.
- [Button Group](https://ui.shadcn.com/docs/components/button-group): Group multiple buttons together.
@@ -39,7 +39,6 @@
- [Checkbox](https://ui.shadcn.com/docs/components/checkbox): Checkbox input component.
- [Radio Group](https://ui.shadcn.com/docs/components/radio-group): Radio button group component.
- [Select](https://ui.shadcn.com/docs/components/select): Select dropdown component.
- [Native Select](https://ui.shadcn.com/docs/components/native-select): Styled native HTML select element.
- [Switch](https://ui.shadcn.com/docs/components/switch): Toggle switch component.
- [Slider](https://ui.shadcn.com/docs/components/slider): Slider input component.
- [Calendar](https://ui.shadcn.com/docs/components/calendar): Calendar component for date selection.
@@ -76,7 +75,6 @@
- [Alert](https://ui.shadcn.com/docs/components/alert): Alert component for messages and notifications.
- [Toast](https://ui.shadcn.com/docs/components/toast): Toast notification component using Sonner.
- [Sonner](https://ui.shadcn.com/docs/components/sonner): Opinionated toast component for React.
- [Progress](https://ui.shadcn.com/docs/components/progress): Progress bar component.
- [Spinner](https://ui.shadcn.com/docs/components/spinner): Loading spinner component.
- [Skeleton](https://ui.shadcn.com/docs/components/skeleton): Skeleton loading placeholder.
@@ -102,7 +100,6 @@
- [Toggle](https://ui.shadcn.com/docs/components/toggle): Toggle button component.
- [Toggle Group](https://ui.shadcn.com/docs/components/toggle-group): Group of toggle buttons.
- [Pagination](https://ui.shadcn.com/docs/components/pagination): Pagination component for lists and tables.
- [Direction](https://ui.shadcn.com/docs/components/direction): Text direction provider for RTL support.
## Dark Mode
@@ -112,13 +109,6 @@
- [Dark Mode - Astro](https://ui.shadcn.com/docs/dark-mode/astro): Dark mode setup for Astro.
- [Dark Mode - Remix](https://ui.shadcn.com/docs/dark-mode/remix): Dark mode setup for Remix.
## RTL
- [RTL](https://ui.shadcn.com/docs/rtl): Overview of right-to-left language support.
- [RTL - Next.js](https://ui.shadcn.com/docs/rtl/next): RTL setup for Next.js.
- [RTL - Vite](https://ui.shadcn.com/docs/rtl/vite): RTL setup for Vite.
- [RTL - TanStack Start](https://ui.shadcn.com/docs/rtl/start): RTL setup for TanStack Start.
## Forms
- [Forms Overview](https://ui.shadcn.com/docs/forms): Guide to building forms with shadcn/ui.
@@ -147,11 +137,6 @@
- [FAQ](https://ui.shadcn.com/docs/registry/faq): Common questions about registries.
- [Authentication](https://ui.shadcn.com/docs/registry/authentication): Adding authentication to your registry.
- [Registry MCP](https://ui.shadcn.com/docs/registry/mcp): MCP integration for registries.
- [Namespaces](https://ui.shadcn.com/docs/registry/namespace): Using multiple registries with namespace support.
- [Add a Registry](https://ui.shadcn.com/docs/registry/registry-index): Open source registry index and how to submit yours.
- [Open in v0](https://ui.shadcn.com/docs/registry/open-in-v0): Integrating your registry with Open in v0.
- [registry.json](https://ui.shadcn.com/docs/registry/registry-json): `registry.json` schema for your own registry.
- [registry-item.json](https://ui.shadcn.com/docs/registry/registry-item-json): `registry-item.json` specification for registry items.
### Registry Schemas

View File

@@ -632,7 +632,7 @@
{
"name": "@shadcn-editor",
"homepage": "https://shadcn-editor.vercel.app",
"url": "https://raw.githubusercontent.com/htmujahid/shadcn-editor/refs/heads/main/public/r/{name}.json",
"url": "https://shadcn-editor.vercel.app/r/{name}.json",
"description": "Accessible, Customizable, Rich Text Editor. Made with Lexical and Shadcn/UI. Open Source. Open Code."
},
{
@@ -809,6 +809,12 @@
"url": "https://shadcnspace.com/r/{name}.json",
"description": "ShadcnSpace is a collection of extra-ordinary, highly customizable shadcn/ui components, blocks, and themes to build modern UIs with speed and clarity."
},
{
"name": "@shadcn-dashboard",
"homepage": "https://shadcn-dashboard.com",
"url": "https://shadcn-dashboard.com/r/{name}.json",
"description": "ShadcnDashboard is a collection of modern, production-ready dashboard layouts, components, and UI patterns built on top of shadcn/ui and Tailwind CSS. Its designed to help developers build clean, scalable, and data-driven dashboards faster—without compromising on performance, accessibility, or customization."
},
{
"name": "@icons-animated",
"homepage": "https://icons.lndev.me",
@@ -1018,17 +1024,5 @@
"homepage": "https://flowkit-ui.vzkiss.com",
"url": "https://flowkit-ui.vzkiss.com/r/{name}.json",
"description": "Opinionated, accessible components on Base UI and shadcn-style primitives — starting with a Creatable Combobox."
},
{
"name": "@termcn",
"homepage": "https://termcn.vercel.app",
"url": "https://termcn.vercel.app/r/{name}.json",
"description": "Beautiful terminal UIs, made simple. Ready to use, customizable terminal UI components for React."
},
{
"name": "@remocn",
"homepage": "https://www.remocn.dev/",
"url": "https://www.remocn.dev/r/{name}.json",
"description": "Production-ready components for Remotion - text animations, backgrounds, transitions, UI blocks, and full scene compositions"
}
]

File diff suppressed because one or more lines are too long

View File

@@ -1,473 +0,0 @@
export const TAILWIND_COLOR_SCALES = [
"50",
"100",
"200",
"300",
"400",
"500",
"600",
"700",
"800",
"900",
"950",
] as const
export const TAILWIND_COLOR_FAMILIES = [
"red",
"orange",
"amber",
"yellow",
"lime",
"green",
"emerald",
"teal",
"cyan",
"sky",
"blue",
"indigo",
"violet",
"purple",
"fuchsia",
"pink",
"rose",
"slate",
"gray",
"zinc",
"neutral",
"stone",
"mauve",
"olive",
"mist",
"taupe",
] as const
export type TailwindColorScale = (typeof TAILWIND_COLOR_SCALES)[number]
export type TailwindColorFamily = (typeof TAILWIND_COLOR_FAMILIES)[number]
function parseCatalog<T>(catalog: string) {
return JSON.parse(catalog) as T
}
export const TAILWIND_COLORS = parseCatalog<
Record<TailwindColorFamily, Record<TailwindColorScale, string>>
>(String.raw`{
"red": {
"50": "oklch(97.1% 0.013 17.38)",
"100": "oklch(93.6% 0.032 17.717)",
"200": "oklch(88.5% 0.062 18.334)",
"300": "oklch(80.8% 0.114 19.571)",
"400": "oklch(70.4% 0.191 22.216)",
"500": "oklch(63.7% 0.237 25.331)",
"600": "oklch(57.7% 0.245 27.325)",
"700": "oklch(50.5% 0.213 27.518)",
"800": "oklch(44.4% 0.177 26.899)",
"900": "oklch(39.6% 0.141 25.723)",
"950": "oklch(25.8% 0.092 26.042)"
},
"orange": {
"50": "oklch(98% 0.016 73.684)",
"100": "oklch(95.4% 0.038 75.164)",
"200": "oklch(90.1% 0.076 70.697)",
"300": "oklch(83.7% 0.128 66.29)",
"400": "oklch(75% 0.183 55.934)",
"500": "oklch(70.5% 0.213 47.604)",
"600": "oklch(64.6% 0.222 41.116)",
"700": "oklch(55.3% 0.195 38.402)",
"800": "oklch(47% 0.157 37.304)",
"900": "oklch(40.8% 0.123 38.172)",
"950": "oklch(26.6% 0.079 36.259)"
},
"amber": {
"50": "oklch(98.7% 0.022 95.277)",
"100": "oklch(96.2% 0.059 95.617)",
"200": "oklch(92.4% 0.12 95.746)",
"300": "oklch(87.9% 0.169 91.605)",
"400": "oklch(82.8% 0.189 84.429)",
"500": "oklch(76.9% 0.188 70.08)",
"600": "oklch(66.6% 0.179 58.318)",
"700": "oklch(55.5% 0.163 48.998)",
"800": "oklch(47.3% 0.137 46.201)",
"900": "oklch(41.4% 0.112 45.904)",
"950": "oklch(27.9% 0.077 45.635)"
},
"yellow": {
"50": "oklch(98.7% 0.026 102.212)",
"100": "oklch(97.3% 0.071 103.193)",
"200": "oklch(94.5% 0.129 101.54)",
"300": "oklch(90.5% 0.182 98.111)",
"400": "oklch(85.2% 0.199 91.936)",
"500": "oklch(79.5% 0.184 86.047)",
"600": "oklch(68.1% 0.162 75.834)",
"700": "oklch(55.4% 0.135 66.442)",
"800": "oklch(47.6% 0.114 61.907)",
"900": "oklch(42.1% 0.095 57.708)",
"950": "oklch(28.6% 0.066 53.813)"
},
"lime": {
"50": "oklch(98.6% 0.031 120.757)",
"100": "oklch(96.7% 0.067 122.328)",
"200": "oklch(93.8% 0.127 124.321)",
"300": "oklch(89.7% 0.196 126.665)",
"400": "oklch(84.1% 0.238 128.85)",
"500": "oklch(76.8% 0.233 130.85)",
"600": "oklch(64.8% 0.2 131.684)",
"700": "oklch(53.2% 0.157 131.589)",
"800": "oklch(45.3% 0.124 130.933)",
"900": "oklch(40.5% 0.101 131.063)",
"950": "oklch(27.4% 0.072 132.109)"
},
"green": {
"50": "oklch(98.2% 0.018 155.826)",
"100": "oklch(96.2% 0.044 156.743)",
"200": "oklch(92.5% 0.084 155.995)",
"300": "oklch(87.1% 0.15 154.449)",
"400": "oklch(79.2% 0.209 151.711)",
"500": "oklch(72.3% 0.219 149.579)",
"600": "oklch(62.7% 0.194 149.214)",
"700": "oklch(52.7% 0.154 150.069)",
"800": "oklch(44.8% 0.119 151.328)",
"900": "oklch(39.3% 0.095 152.535)",
"950": "oklch(26.6% 0.065 152.934)"
},
"emerald": {
"50": "oklch(97.9% 0.021 166.113)",
"100": "oklch(95% 0.052 163.051)",
"200": "oklch(90.5% 0.093 164.15)",
"300": "oklch(84.5% 0.143 164.978)",
"400": "oklch(76.5% 0.177 163.223)",
"500": "oklch(69.6% 0.17 162.48)",
"600": "oklch(59.6% 0.145 163.225)",
"700": "oklch(50.8% 0.118 165.612)",
"800": "oklch(43.2% 0.095 166.913)",
"900": "oklch(37.8% 0.077 168.94)",
"950": "oklch(26.2% 0.051 172.552)"
},
"teal": {
"50": "oklch(98.4% 0.014 180.72)",
"100": "oklch(95.3% 0.051 180.801)",
"200": "oklch(91% 0.096 180.426)",
"300": "oklch(85.5% 0.138 181.071)",
"400": "oklch(77.7% 0.152 181.912)",
"500": "oklch(70.4% 0.14 182.503)",
"600": "oklch(60% 0.118 184.704)",
"700": "oklch(51.1% 0.096 186.391)",
"800": "oklch(43.7% 0.078 188.216)",
"900": "oklch(38.6% 0.063 188.416)",
"950": "oklch(27.7% 0.046 192.524)"
},
"cyan": {
"50": "oklch(98.4% 0.019 200.873)",
"100": "oklch(95.6% 0.045 203.388)",
"200": "oklch(91.7% 0.08 205.041)",
"300": "oklch(86.5% 0.127 207.078)",
"400": "oklch(78.9% 0.154 211.53)",
"500": "oklch(71.5% 0.143 215.221)",
"600": "oklch(60.9% 0.126 221.723)",
"700": "oklch(52% 0.105 223.128)",
"800": "oklch(45% 0.085 224.283)",
"900": "oklch(39.8% 0.07 227.392)",
"950": "oklch(30.2% 0.056 229.695)"
},
"sky": {
"50": "oklch(97.7% 0.013 236.62)",
"100": "oklch(95.1% 0.026 236.824)",
"200": "oklch(90.1% 0.058 230.902)",
"300": "oklch(82.8% 0.111 230.318)",
"400": "oklch(74.6% 0.16 232.661)",
"500": "oklch(68.5% 0.169 237.323)",
"600": "oklch(58.8% 0.158 241.966)",
"700": "oklch(50% 0.134 242.749)",
"800": "oklch(44.3% 0.11 240.79)",
"900": "oklch(39.1% 0.09 240.876)",
"950": "oklch(29.3% 0.066 243.157)"
},
"blue": {
"50": "oklch(97% 0.014 254.604)",
"100": "oklch(93.2% 0.032 255.585)",
"200": "oklch(88.2% 0.059 254.128)",
"300": "oklch(80.9% 0.105 251.813)",
"400": "oklch(70.7% 0.165 254.624)",
"500": "oklch(62.3% 0.214 259.815)",
"600": "oklch(54.6% 0.245 262.881)",
"700": "oklch(48.8% 0.243 264.376)",
"800": "oklch(42.4% 0.199 265.638)",
"900": "oklch(37.9% 0.146 265.522)",
"950": "oklch(28.2% 0.091 267.935)"
},
"indigo": {
"50": "oklch(96.2% 0.018 272.314)",
"100": "oklch(93% 0.034 272.788)",
"200": "oklch(87% 0.065 274.039)",
"300": "oklch(78.5% 0.115 274.713)",
"400": "oklch(67.3% 0.182 276.935)",
"500": "oklch(58.5% 0.233 277.117)",
"600": "oklch(51.1% 0.262 276.966)",
"700": "oklch(45.7% 0.24 277.023)",
"800": "oklch(39.8% 0.195 277.366)",
"900": "oklch(35.9% 0.144 278.697)",
"950": "oklch(25.7% 0.09 281.288)"
},
"violet": {
"50": "oklch(96.9% 0.016 293.756)",
"100": "oklch(94.3% 0.029 294.588)",
"200": "oklch(89.4% 0.057 293.283)",
"300": "oklch(81.1% 0.111 293.571)",
"400": "oklch(70.2% 0.183 293.541)",
"500": "oklch(60.6% 0.25 292.717)",
"600": "oklch(54.1% 0.281 293.009)",
"700": "oklch(49.1% 0.27 292.581)",
"800": "oklch(43.2% 0.232 292.759)",
"900": "oklch(38% 0.189 293.745)",
"950": "oklch(28.3% 0.141 291.089)"
},
"purple": {
"50": "oklch(97.7% 0.014 308.299)",
"100": "oklch(94.6% 0.033 307.174)",
"200": "oklch(90.2% 0.063 306.703)",
"300": "oklch(82.7% 0.119 306.383)",
"400": "oklch(71.4% 0.203 305.504)",
"500": "oklch(62.7% 0.265 303.9)",
"600": "oklch(55.8% 0.288 302.321)",
"700": "oklch(49.6% 0.265 301.924)",
"800": "oklch(43.8% 0.218 303.724)",
"900": "oklch(38.1% 0.176 304.987)",
"950": "oklch(29.1% 0.149 302.717)"
},
"fuchsia": {
"50": "oklch(97.7% 0.017 320.058)",
"100": "oklch(95.2% 0.037 318.852)",
"200": "oklch(90.3% 0.076 319.62)",
"300": "oklch(83.3% 0.145 321.434)",
"400": "oklch(74% 0.238 322.16)",
"500": "oklch(66.7% 0.295 322.15)",
"600": "oklch(59.1% 0.293 322.896)",
"700": "oklch(51.8% 0.253 323.949)",
"800": "oklch(45.2% 0.211 324.591)",
"900": "oklch(40.1% 0.17 325.612)",
"950": "oklch(29.3% 0.136 325.661)"
},
"pink": {
"50": "oklch(97.1% 0.014 343.198)",
"100": "oklch(94.8% 0.028 342.258)",
"200": "oklch(89.9% 0.061 343.231)",
"300": "oklch(82.3% 0.12 346.018)",
"400": "oklch(71.8% 0.202 349.761)",
"500": "oklch(65.6% 0.241 354.308)",
"600": "oklch(59.2% 0.249 0.584)",
"700": "oklch(52.5% 0.223 3.958)",
"800": "oklch(45.9% 0.187 3.815)",
"900": "oklch(40.8% 0.153 2.432)",
"950": "oklch(28.4% 0.109 3.907)"
},
"rose": {
"50": "oklch(96.9% 0.015 12.422)",
"100": "oklch(94.1% 0.03 12.58)",
"200": "oklch(89.2% 0.058 10.001)",
"300": "oklch(81% 0.117 11.638)",
"400": "oklch(71.2% 0.194 13.428)",
"500": "oklch(64.5% 0.246 16.439)",
"600": "oklch(58.6% 0.253 17.585)",
"700": "oklch(51.4% 0.222 16.935)",
"800": "oklch(45.5% 0.188 13.697)",
"900": "oklch(41% 0.159 10.272)",
"950": "oklch(27.1% 0.105 12.094)"
},
"slate": {
"50": "oklch(98.4% 0.003 247.858)",
"100": "oklch(96.8% 0.007 247.896)",
"200": "oklch(92.9% 0.013 255.508)",
"300": "oklch(86.9% 0.022 252.894)",
"400": "oklch(70.4% 0.04 256.788)",
"500": "oklch(55.4% 0.046 257.417)",
"600": "oklch(44.6% 0.043 257.281)",
"700": "oklch(37.2% 0.044 257.287)",
"800": "oklch(27.9% 0.041 260.031)",
"900": "oklch(20.8% 0.042 265.755)",
"950": "oklch(12.9% 0.042 264.695)"
},
"gray": {
"50": "oklch(98.5% 0.002 247.839)",
"100": "oklch(96.7% 0.003 264.542)",
"200": "oklch(92.8% 0.006 264.531)",
"300": "oklch(87.2% 0.01 258.338)",
"400": "oklch(70.7% 0.022 261.325)",
"500": "oklch(55.1% 0.027 264.364)",
"600": "oklch(44.6% 0.03 256.802)",
"700": "oklch(37.3% 0.034 259.733)",
"800": "oklch(27.8% 0.033 256.848)",
"900": "oklch(21% 0.034 264.665)",
"950": "oklch(13% 0.028 261.692)"
},
"zinc": {
"50": "oklch(98.5% 0 0)",
"100": "oklch(96.7% 0.001 286.375)",
"200": "oklch(92% 0.004 286.32)",
"300": "oklch(87.1% 0.006 286.286)",
"400": "oklch(70.5% 0.015 286.067)",
"500": "oklch(55.2% 0.016 285.938)",
"600": "oklch(44.2% 0.017 285.786)",
"700": "oklch(37% 0.013 285.805)",
"800": "oklch(27.4% 0.006 286.033)",
"900": "oklch(21% 0.006 285.885)",
"950": "oklch(14.1% 0.005 285.823)"
},
"neutral": {
"50": "oklch(98.5% 0 0)",
"100": "oklch(97% 0 0)",
"200": "oklch(92.2% 0 0)",
"300": "oklch(87% 0 0)",
"400": "oklch(70.8% 0 0)",
"500": "oklch(55.6% 0 0)",
"600": "oklch(43.9% 0 0)",
"700": "oklch(37.1% 0 0)",
"800": "oklch(26.9% 0 0)",
"900": "oklch(20.5% 0 0)",
"950": "oklch(14.5% 0 0)"
},
"stone": {
"50": "oklch(98.5% 0.001 106.423)",
"100": "oklch(97% 0.001 106.424)",
"200": "oklch(92.3% 0.003 48.717)",
"300": "oklch(86.9% 0.005 56.366)",
"400": "oklch(70.9% 0.01 56.259)",
"500": "oklch(55.3% 0.013 58.071)",
"600": "oklch(44.4% 0.011 73.639)",
"700": "oklch(37.4% 0.01 67.558)",
"800": "oklch(26.8% 0.007 34.298)",
"900": "oklch(21.6% 0.006 56.043)",
"950": "oklch(14.7% 0.004 49.25)"
},
"mauve": {
"50": "oklch(98.5% 0 0)",
"100": "oklch(96% 0.003 325.6)",
"200": "oklch(92.2% 0.005 325.62)",
"300": "oklch(86.5% 0.012 325.68)",
"400": "oklch(71.1% 0.019 323.02)",
"500": "oklch(54.2% 0.034 322.5)",
"600": "oklch(43.5% 0.029 321.78)",
"700": "oklch(36.4% 0.029 323.89)",
"800": "oklch(26.3% 0.024 320.12)",
"900": "oklch(21.2% 0.019 322.12)",
"950": "oklch(14.5% 0.008 326)"
},
"olive": {
"50": "oklch(98.8% 0.003 106.5)",
"100": "oklch(96.6% 0.005 106.5)",
"200": "oklch(93% 0.007 106.5)",
"300": "oklch(88% 0.011 106.6)",
"400": "oklch(73.7% 0.021 106.9)",
"500": "oklch(58% 0.031 107.3)",
"600": "oklch(46.6% 0.025 107.3)",
"700": "oklch(39.4% 0.023 107.4)",
"800": "oklch(28.6% 0.016 107.4)",
"900": "oklch(22.8% 0.013 107.4)",
"950": "oklch(15.3% 0.006 107.1)"
},
"mist": {
"50": "oklch(98.7% 0.002 197.1)",
"100": "oklch(96.3% 0.002 197.1)",
"200": "oklch(92.5% 0.005 214.3)",
"300": "oklch(87.2% 0.007 219.6)",
"400": "oklch(72.3% 0.014 214.4)",
"500": "oklch(56% 0.021 213.5)",
"600": "oklch(45% 0.017 213.2)",
"700": "oklch(37.8% 0.015 216)",
"800": "oklch(27.5% 0.011 216.9)",
"900": "oklch(21.8% 0.008 223.9)",
"950": "oklch(14.8% 0.004 228.8)"
},
"taupe": {
"50": "oklch(98.6% 0.002 67.8)",
"100": "oklch(96% 0.002 17.2)",
"200": "oklch(92.2% 0.005 34.3)",
"300": "oklch(86.8% 0.007 39.5)",
"400": "oklch(71.4% 0.014 41.2)",
"500": "oklch(54.7% 0.021 43.1)",
"600": "oklch(43.8% 0.017 39.3)",
"700": "oklch(36.7% 0.016 35.7)",
"800": "oklch(26.8% 0.011 36.5)",
"900": "oklch(21.4% 0.009 43.1)",
"950": "oklch(14.7% 0.004 49.3)"
}
}`)
const LEGACY_COLOR_FAMILY_ALIASES: Record<string, TailwindColorFamily> = {
"220.9 39.3% 11%": "gray",
"210 20% 98%": "gray",
"12 76% 61%": "gray",
"220 70% 50%": "gray",
}
const TAILWIND_COLOR_VALUE_TO_FAMILY = new Map<string, TailwindColorFamily>()
for (const family of TAILWIND_COLOR_FAMILIES) {
for (const scale of TAILWIND_COLOR_SCALES) {
TAILWIND_COLOR_VALUE_TO_FAMILY.set(
normalizeColorValue(TAILWIND_COLORS[family][scale]),
family
)
}
}
export function findTailwindColorFamily(
value: string | undefined
) {
const normalized = normalizeColorValue(value)
if (!normalized) {
return null
}
return (
TAILWIND_COLOR_VALUE_TO_FAMILY.get(normalized) ??
LEGACY_COLOR_FAMILY_ALIASES[normalized] ??
null
)
}
export function normalizeColorValue(value: string | undefined) {
if (!value) {
return ""
}
const normalized = value.trim().replace(/\s+/g, " ").toLowerCase()
if (!normalized.startsWith("oklch(") || !normalized.endsWith(")")) {
return normalized
}
const inner = normalized.slice(6, -1).trim()
const [color] = inner.split(/\s*\/\s*/)
const parts = color.split(/\s+/)
if (parts.length < 3) {
return normalized
}
const lightness = normalizeColorNumber(parts[0], { percentage: true })
const chroma = normalizeColorNumber(parts[1])
const hue = normalizeColorNumber(parts[2])
return `oklch(${lightness} ${chroma} ${hue})`
}
function normalizeColorNumber(
value: string,
options: {
percentage?: boolean
} = {}
) {
if (options.percentage && value.endsWith("%")) {
return formatColorNumber(Number.parseFloat(value) / 100)
}
return formatColorNumber(Number.parseFloat(value))
}
function formatColorNumber(value: number) {
if (Number.isNaN(value)) {
return ""
}
return Number(value.toFixed(12)).toString()
}

View File

@@ -1,6 +1,5 @@
import { existsSync } from "fs"
import path from "path"
import { resolveProjectPreset } from "@/src/preset/resolve"
import { SHADCN_URL } from "@/src/registry/constants"
import { getBase, getConfig } from "@/src/utils/get-config"
import {
@@ -65,7 +64,7 @@ export const info = new Command()
const config = await getConfig(cwd)
const components = await getProjectComponents(cwd)
const base = getBase(config?.style)
const data = await collectInfo(projectInfo, config, components, base)
const data = collectInfo(projectInfo, config, components, base)
if (opts.json) {
console.log(JSON.stringify(data, null, 2))
@@ -92,16 +91,12 @@ function getRegistries(
return result
}
export async function collectInfo(
function collectInfo(
projectInfo: ProjectInfo | null,
config: Awaited<ReturnType<typeof getConfig>>,
components: string[],
base: string
) {
const preset = config
? await resolveProjectPreset(config, projectInfo)
: null
return {
project: projectInfo
? {
@@ -147,7 +142,6 @@ export async function collectInfo(
registries: getRegistries(config.registries),
}
: null,
preset,
components,
links: {
docs: `${SHADCN_URL}/docs`,
@@ -159,7 +153,7 @@ export async function collectInfo(
}
}
export function printInfo(data: Awaited<ReturnType<typeof collectInfo>>) {
function printInfo(data: ReturnType<typeof collectInfo>) {
// Project.
logger.log(highlighter.info("Project"))
if (data.project) {
@@ -193,29 +187,6 @@ export function printInfo(data: Awaited<ReturnType<typeof collectInfo>>) {
menuAccent: data.config.menuAccent ?? "-",
})
logger.break()
logger.log(highlighter.info("Preset"))
if (!data.preset?.code) {
printEntries({
"--preset": "-",
})
} else {
printEntries({
"--preset": data.preset.code,
url: `${SHADCN_URL}/create?preset=${data.preset.code}`,
style: data.preset.values?.style ?? "-",
baseColor: data.preset.values?.baseColor ?? "-",
theme: data.preset.values?.theme ?? "-",
chartColor: data.preset.values?.chartColor ?? "-",
iconLibrary: data.preset.values?.iconLibrary ?? "-",
font: data.preset.values?.font ?? "-",
fontHeading: data.preset.values?.fontHeading ?? "-",
radius: data.preset.values?.radius ?? "-",
menuAccent: data.preset.values?.menuAccent ?? "-",
menuColor: data.preset.values?.menuColor ?? "-",
})
}
// Aliases.
logger.break()
logger.log(highlighter.info("Aliases"))

View File

@@ -21,6 +21,7 @@ import {
templates,
} from "@/src/templates/index"
import { addComponents } from "@/src/utils/add-components"
import { getInitAliasDefaults } from "@/src/utils/alias"
import { createProject } from "@/src/utils/create-project"
import { loadEnvFiles } from "@/src/utils/env-loader"
import * as ERRORS from "@/src/utils/errors"
@@ -565,6 +566,7 @@ export async function runInit(
}
) {
let projectInfo
let projectConfig
let newProjectTemplate: keyof typeof templates | undefined
// Resolve the effective template if --monorepo is set.
@@ -606,6 +608,8 @@ export async function runInit(
projectInfo = await getProjectInfo(options.cwd)
}
projectConfig = await getProjectConfig(options.cwd, projectInfo)
// Use the template from project creation if available,
// or fall back to the explicit --template flag.
const templateKey = newProjectTemplate ?? explicitTemplate
@@ -619,6 +623,9 @@ export async function runInit(
// Add button component for new template-based projects.
...(selectedTemplate ? ["button"] : []),
]
const templatePostInit = options.isNewProject
? selectedTemplate?.postInit
: undefined
if (selectedTemplate?.init) {
const result = await selectedTemplate.init({
@@ -632,15 +639,15 @@ export async function runInit(
silent: options.silent,
})
// Run postInit for new projects (e.g. git init).
await selectedTemplate.postInit({ projectPath: options.cwd })
if (templatePostInit) {
// Run postInit for newly scaffolded projects (e.g. git init).
await templatePostInit({ projectPath: options.cwd })
}
return result
}
// Standard init path for existing projects.
const projectConfig = await getProjectConfig(options.cwd, projectInfo)
let config = projectConfig
? await promptForMinimalConfig(projectConfig, options)
: await promptForConfig(await getConfig(options.cwd))
@@ -770,9 +777,9 @@ export async function runInit(
options.isNewProject || projectInfo?.framework.name === "next-app",
})
// Run postInit for new projects without a custom init (e.g. git init).
if (selectedTemplate) {
await selectedTemplate.postInit({ projectPath: options.cwd })
// Run postInit only for newly scaffolded projects.
if (templatePostInit) {
await templatePostInit({ projectPath: options.cwd })
}
return fullConfig
@@ -856,12 +863,6 @@ async function promptForConfig(defaultConfig: Config | null = null) {
)}:`,
initial: defaultConfig?.aliases["components"] ?? DEFAULT_COMPONENTS,
},
{
type: "text",
name: "utils",
message: `Configure the import alias for ${highlighter.info("utils")}:`,
initial: defaultConfig?.aliases["utils"] ?? DEFAULT_UTILS,
},
{
type: "toggle",
name: "rsc",
@@ -876,6 +877,16 @@ async function promptForConfig(defaultConfig: Config | null = null) {
process.exit(1)
}
const existingAliases =
defaultConfig && defaultConfig.aliases.components === options.components
? defaultConfig.aliases
: undefined
const aliasDefaults = getInitAliasDefaults(
options.components,
existingAliases
)
return rawConfigSchema.parse({
$schema: "https://ui.shadcn.com/schema.json",
style: options.style,
@@ -889,11 +900,11 @@ async function promptForConfig(defaultConfig: Config | null = null) {
rsc: options.rsc,
tsx: options.typescript,
aliases: {
utils: options.utils,
components: options.components,
// TODO: fix this.
lib: options.components.replace(/\/components$/, "lib"),
hooks: options.components.replace(/\/components$/, "hooks"),
ui: aliasDefaults.ui,
lib: aliasDefaults.lib,
hooks: aliasDefaults.hooks,
utils: aliasDefaults.utils,
},
})
}

View File

@@ -0,0 +1,140 @@
import { preFlightInit } from "@/src/preflights/preflight-init"
import { afterEach, describe, expect, test, vi } from "vitest"
import { z } from "zod"
const { mockedGetProjectInfo, mockedExistsSync, mockedLogger } = vi.hoisted(
() => ({
mockedGetProjectInfo: vi.fn(),
mockedExistsSync: vi.fn(),
mockedLogger: {
break: vi.fn(),
error: vi.fn(),
},
})
)
vi.mock("@/src/commands/init", () => ({
initOptionsSchema: z.object({
cwd: z.string(),
force: z.boolean(),
monorepo: z.boolean().optional(),
silent: z.boolean().optional(),
existingConfig: z.record(z.unknown()).optional(),
}),
}))
vi.mock("@/src/utils/get-project-info", () => ({
getProjectInfo: mockedGetProjectInfo,
}))
vi.mock("@/src/utils/get-monorepo-info", () => ({
formatMonorepoMessage: vi.fn(),
getMonorepoTargets: vi.fn().mockResolvedValue([]),
isMonorepoRoot: vi.fn().mockResolvedValue(false),
}))
vi.mock("@/src/utils/highlighter", () => ({
highlighter: {
info: (value: string) => value,
},
}))
vi.mock("@/src/utils/logger", () => ({
logger: mockedLogger,
}))
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn().mockReturnValue({
start: vi.fn().mockReturnValue({
succeed: vi.fn(),
fail: vi.fn(),
stop: vi.fn(),
}),
}),
}))
vi.mock("fs-extra", () => ({
default: {
existsSync: mockedExistsSync,
},
}))
const baseProjectInfo = {
framework: {
name: "next-app",
label: "Next.js",
links: {
installation: "https://ui.shadcn.com/docs/installation",
tailwind: "https://tailwindcss.com/docs/installation",
},
},
isSrcDir: false,
isRSC: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "app/globals.css",
tailwindVersion: "v4" as const,
frameworkVersion: null,
aliasPrefix: "#",
}
const baseOptions = {
cwd: "/tmp/project",
cssVariables: true,
defaults: false,
force: false,
installStyleIndex: true,
isNewProject: false,
monorepo: false,
silent: true,
yes: true,
}
afterEach(() => {
vi.clearAllMocks()
})
describe("preFlightInit", () => {
test("accepts package import aliases detected from package.json#imports", async () => {
mockedExistsSync.mockImplementation((filePath: string) => {
return !filePath.endsWith("components.json")
})
mockedGetProjectInfo.mockResolvedValue(baseProjectInfo)
const result = await preFlightInit(baseOptions)
expect(result.errors).toEqual({})
expect(result.projectInfo?.aliasPrefix).toBe("#")
expect(mockedLogger.error).not.toHaveBeenCalled()
})
test("reports missing aliases for tsconfig paths and package imports", async () => {
mockedExistsSync.mockImplementation((filePath: string) => {
return !filePath.endsWith("components.json")
})
mockedGetProjectInfo.mockResolvedValue({
...baseProjectInfo,
aliasPrefix: null,
})
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((
code?: string | number | null
) => {
throw new Error(`process.exit:${code ?? ""}`)
}) as never)
await expect(preFlightInit(baseOptions)).rejects.toThrow("process.exit:1")
expect(mockedLogger.error).toHaveBeenCalledWith(
"Could not find valid path aliases or package imports for init."
)
expect(mockedLogger.error).toHaveBeenCalledWith(
"Configure path aliases in tsconfig.json or imports in package.json, then run init again."
)
expect(mockedLogger.error).toHaveBeenCalledWith(
"Learn more at https://ui.shadcn.com/docs/installation/manual#configure-import-aliases."
)
exitSpy.mockRestore()
})
})

View File

@@ -1,5 +1,6 @@
import path from "path"
import { initOptionsSchema } from "@/src/commands/init"
import { SHADCN_URL } from "@/src/registry/constants"
import * as ERRORS from "@/src/utils/errors"
import {
formatMonorepoMessage,
@@ -132,6 +133,7 @@ export async function preFlightInit(
const tsConfigSpinner = spinner(`Validating import alias.`, {
silent: options.silent,
}).start()
if (!projectInfo?.aliasPrefix) {
errors[ERRORS.IMPORT_ALIAS_MISSING] = true
tsConfigSpinner?.fail()
@@ -162,14 +164,23 @@ export async function preFlightInit(
if (errors[ERRORS.IMPORT_ALIAS_MISSING]) {
logger.break()
logger.error(`No import alias found in your tsconfig.json file.`)
if (projectInfo?.framework.links.installation) {
logger.error(
`Visit ${highlighter.info(
projectInfo?.framework.links.installation
)} to learn how to set an import alias.`
)
}
logger.error(
`Could not find valid path aliases or package imports for ${highlighter.info(
"init"
)}.`
)
logger.error(
`Configure path aliases in ${highlighter.info(
"tsconfig.json"
)} or imports in ${highlighter.info("package.json")}, then run ${highlighter.info(
"init"
)} again.`
)
logger.error(
`Learn more at ${highlighter.info(
`${SHADCN_URL}/docs/installation/manual#configure-import-aliases`
)}.`
)
}
logger.break()

View File

@@ -1,101 +0,0 @@
import type { PresetConfig } from "./preset"
export const DEFAULT_PRESETS = {
nova: {
title: "Nova",
description: "Lucide / Geist",
style: "nova",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "geist",
fontHeading: "inherit",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
rtl: false,
},
vega: {
title: "Vega",
description: "Lucide / Inter",
style: "vega",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
rtl: false,
},
maia: {
title: "Maia",
description: "Hugeicons / Figtree",
style: "maia",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "hugeicons",
font: "figtree",
fontHeading: "inherit",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
rtl: false,
},
lyra: {
title: "Lyra",
description: "Phosphor / JetBrains Mono",
style: "lyra",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "phosphor",
font: "jetbrains-mono",
fontHeading: "inherit",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
rtl: false,
},
mira: {
title: "Mira",
description: "Hugeicons / Inter",
style: "mira",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "hugeicons",
font: "inter",
fontHeading: "inherit",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
rtl: false,
},
luma: {
title: "Luma",
description: "Lucide / Inter",
style: "luma",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
rtl: false,
},
} satisfies Record<
PresetConfig["style"],
PresetConfig & {
description: string
rtl: boolean
title: string
}
>

View File

@@ -79,12 +79,6 @@ describe("buildInitUrl", () => {
expect(parsed.searchParams.get("chartColor")).toBe("emerald")
})
it("should not include chartColor when it is neutral", () => {
const url = resolveInitUrl({ ...mockPreset, chartColor: "neutral" })
const parsed = new URL(url)
expect(parsed.searchParams.has("chartColor")).toBe(false)
})
it("should not include chartColor when not provided", () => {
const url = resolveInitUrl(mockPreset)
const parsed = new URL(url)

View File

@@ -11,9 +11,99 @@ import { ensureRegistriesInConfig } from "@/src/utils/registries"
import open from "open"
import prompts from "prompts"
import { type z } from "zod"
import { DEFAULT_PRESETS } from "./defaults"
export { DEFAULT_PRESETS } from "./defaults"
export const DEFAULT_PRESETS = {
nova: {
title: "Nova",
description: "Lucide / Geist",
style: "nova",
baseColor: "neutral",
theme: "neutral",
iconLibrary: "lucide",
font: "geist",
fontHeading: "inherit",
menuAccent: "subtle" as const,
menuColor: "default" as const,
radius: "default",
rtl: false,
},
vega: {
title: "Vega",
description: "Lucide / Inter",
style: "vega",
baseColor: "neutral",
theme: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
menuAccent: "subtle" as const,
menuColor: "default" as const,
radius: "default",
rtl: false,
},
maia: {
title: "Maia",
description: "Hugeicons / Figtree",
style: "maia",
baseColor: "neutral",
theme: "neutral",
iconLibrary: "hugeicons",
font: "figtree",
fontHeading: "inherit",
menuAccent: "subtle" as const,
menuColor: "default" as const,
radius: "default",
rtl: false,
},
lyra: {
title: "Lyra",
description: "Phosphor / JetBrains Mono",
style: "lyra",
baseColor: "neutral",
theme: "neutral",
iconLibrary: "phosphor",
font: "jetbrains-mono",
fontHeading: "inherit",
menuAccent: "subtle" as const,
menuColor: "default" as const,
radius: "default",
rtl: false,
},
mira: {
title: "Mira",
description: "Hugeicons / Inter",
style: "mira",
baseColor: "neutral",
theme: "neutral",
iconLibrary: "hugeicons",
font: "inter",
fontHeading: "inherit",
menuAccent: "subtle" as const,
menuColor: "default" as const,
radius: "default",
rtl: false,
},
luma: {
title: "Luma",
description: "Lucide / Inter",
style: "luma",
baseColor: "neutral",
theme: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
menuAccent: "subtle" as const,
menuColor: "default" as const,
radius: "default",
rtl: false,
},
}
export function resolveCreateUrl(
searchParams?: Partial<{
@@ -98,7 +188,7 @@ export function resolveInitUrl(
radius: preset.radius,
})
if (preset.chartColor && preset.chartColor !== "neutral") {
if (preset.chartColor) {
params.set("chartColor", preset.chartColor)
}

View File

@@ -1,275 +0,0 @@
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import { createConfig } from "@/src/utils/get-config"
import { FRAMEWORKS } from "@/src/utils/frameworks"
import { afterEach, describe, expect, it } from "vitest"
import { resolveProjectPreset } from "./resolve"
import { encodePreset, type PresetConfig } from "./preset"
const tempDirs: string[] = []
const presetCssWithHeadingFont = `@import "@fontsource-variable/inter";
@import "@fontsource-variable/lora";
:root {
--radius: 0.875rem;
--primary: oklch(0.488 0.243 264.376);
--primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-primary: oklch(0.546 0.245 262.881);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--chart-1: oklch(0.845 0.143 164.978);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.596 0.145 163.225);
--chart-4: oklch(0.508 0.118 165.612);
--chart-5: oklch(0.432 0.095 166.913);
}
.dark {
--primary: oklch(0.424 0.199 265.638);
--primary-foreground: oklch(0.97 0.014 254.604);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
--chart-1: oklch(0.845 0.143 164.978);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.596 0.145 163.225);
--chart-4: oklch(0.508 0.118 165.612);
--chart-5: oklch(0.432 0.095 166.913);
}
@theme inline {
--font-sans: "Inter Variable", sans-serif;
--font-heading: "Lora Variable", serif;
}`
async function createTestConfig(options: {
css: string
style?: string
baseColor?: string
iconLibrary?: string
menuColor?: PresetConfig["menuColor"]
menuAccent?: PresetConfig["menuAccent"]
files?: Record<string, string>
}) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "shadcn-preset-"))
tempDirs.push(tempDir)
const tailwindCss = path.join(tempDir, "globals.css")
await fs.writeFile(tailwindCss, options.css, "utf8")
for (const [relativePath, content] of Object.entries(options.files ?? {})) {
const filePath = path.join(tempDir, relativePath)
await fs.mkdir(path.dirname(filePath), { recursive: true })
await fs.writeFile(filePath, content, "utf8")
}
return createConfig({
style: options.style ?? "base-luma",
tailwind: {
css: "globals.css",
baseColor: options.baseColor ?? "mist",
cssVariables: true,
prefix: "",
config: "",
},
iconLibrary: options.iconLibrary ?? "phosphor",
menuColor: options.menuColor ?? "inverted",
menuAccent: options.menuAccent ?? "bold",
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
resolvedPaths: {
cwd: tempDir,
tailwindCss,
},
})
}
afterEach(async () => {
await Promise.all(
tempDirs
.splice(0)
.map((dir) => fs.rm(dir, { recursive: true, force: true }))
)
})
describe("resolveProjectPreset", () => {
it("derives preset values and code from project css", async () => {
const config = await createTestConfig({
css: presetCssWithHeadingFont,
})
const result = await resolveProjectPreset(config)
expect(result.values).toEqual({
style: "luma",
baseColor: "mist",
theme: "blue",
chartColor: "emerald",
iconLibrary: "phosphor",
font: "inter",
fontHeading: "lora",
radius: "large",
menuAccent: "bold",
menuColor: "inverted",
})
expect(result.code).toBe(encodePreset(result.values!))
})
it("falls back to preset defaults when css values cannot be matched", async () => {
const config = await createTestConfig({
css: `:root {
--radius: 1rem;
--primary: hotpink;
--chart-1: tomato;
}
.dark {
--primary: rebeccapurple;
--chart-1: orange;
}
@theme inline {
--font-sans: var(--font-sans);
}`,
menuAccent: "subtle",
menuColor: "default",
})
const result = await resolveProjectPreset(config)
expect(result.values).toEqual({
style: "luma",
baseColor: "mist",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "phosphor",
font: "inter",
fontHeading: "inherit",
radius: "default",
menuAccent: "subtle",
menuColor: "default",
})
expect(result.code).toBe(encodePreset(result.values!))
})
it("derives body and heading fonts from next/font declarations in layout.tsx", async () => {
const config = await createTestConfig({
css: `:root {
--radius: 0.875rem;
--primary: oklch(0.623 0.214 259.815);
--chart-1: oklch(0.696 0.17 162.48);
}
.dark {
--primary: oklch(0.623 0.214 259.815);
--chart-1: oklch(0.696 0.17 162.48);
}
@theme inline {
--font-sans: var(--font-sans);
--font-heading: var(--font-heading);
}`,
files: {
"src/app/layout.tsx": `import { Inter, Lora } from "next/font/google"
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" })
const loraHeading = Lora({ subsets: ["latin"], variable: "--font-heading" })
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={\`font-sans \${inter.variable} \${loraHeading.variable}\`}>
<body>{children}</body>
</html>
)
}`,
},
})
const result = await resolveProjectPreset(config, {
framework: FRAMEWORKS["next-app"],
isSrcDir: true,
isRSC: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: config.resolvedPaths.tailwindCss,
tailwindVersion: "v4",
frameworkVersion: "16.0.0",
aliasPrefix: "@",
})
expect(result.values).toMatchObject({
style: "luma",
theme: "blue",
chartColor: "emerald",
font: "inter",
fontHeading: "lora",
radius: "large",
})
expect(result.code).toBe(encodePreset(result.values!))
})
it("derives body font from next-pages _app when only one root font is defined", async () => {
const config = await createTestConfig({
css: `:root {
--radius: 0.625rem;
--primary: oklch(0.205 0 0);
--chart-1: oklch(0.87 0 0);
}
.dark {
--primary: oklch(0.205 0 0);
--chart-1: oklch(0.87 0 0);
}
@theme inline {
--font-sans: var(--font-sans);
}`,
files: {
"src/pages/_app.tsx": `import { Inter } from "next/font/google"
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" })
export default function App({ Component, pageProps }) {
return <main className={inter.variable}><Component {...pageProps} /></main>
}`,
},
})
const result = await resolveProjectPreset(config, {
framework: FRAMEWORKS["next-pages"],
isSrcDir: true,
isRSC: false,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: config.resolvedPaths.tailwindCss,
tailwindVersion: "v4",
frameworkVersion: "16.0.0",
aliasPrefix: "@",
})
expect(result.values).toMatchObject({
style: "luma",
font: "inter",
fontHeading: "inherit",
})
expect(result.code).toBe(encodePreset(result.values!))
})
it("returns null for unsupported legacy styles", async () => {
const config = await createTestConfig({
style: "new-york",
css: `:root { --radius: 0.625rem; }`,
})
await expect(resolveProjectPreset(config)).resolves.toEqual({
code: null,
values: null,
})
})
})

View File

@@ -1,773 +0,0 @@
import { existsSync, promises as fs } from "fs"
import path from "path"
import { findTailwindColorFamily } from "@/src/colors"
import {
getProjectInfo,
type ProjectInfo,
} from "@/src/utils/get-project-info"
import type { Config } from "@/src/utils/get-config"
import postcss from "postcss"
import { Node, Project, ScriptKind, SyntaxKind } from "ts-morph"
import { DEFAULT_PRESETS } from "./defaults"
import {
encodePreset,
PRESET_BASE_COLORS,
PRESET_FONTS,
PRESET_FONT_HEADINGS,
PRESET_ICON_LIBRARIES,
PRESET_MENU_ACCENTS,
PRESET_MENU_COLORS,
PRESET_THEMES,
type PresetConfig,
} from "./preset"
const PRESET_BASE_COLOR_SET = new Set<string>(PRESET_BASE_COLORS)
const PRESET_ICON_LIBRARY_SET = new Set<string>(PRESET_ICON_LIBRARIES)
const PRESET_MENU_ACCENT_SET = new Set<string>(PRESET_MENU_ACCENTS)
const PRESET_MENU_COLOR_SET = new Set<string>(PRESET_MENU_COLORS)
const PRESET_FONT_SET = new Set<string>(PRESET_FONTS)
const PRESET_FONT_HEADING_SET = new Set<string>(PRESET_FONT_HEADINGS)
const PRESET_THEME_SET = new Set<string>(PRESET_THEMES)
const SERIF_FONTS = new Set<PresetConfig["font"]>([
"lora",
"merriweather",
"playfair-display",
"noto-serif",
"roboto-slab",
])
const MONO_FONTS = new Set<PresetConfig["font"]>([
"jetbrains-mono",
"geist-mono",
])
const ROOT_FONT_VARIABLES = [
"--font-sans",
"--font-serif",
"--font-mono",
] as const
type RootFontVariable = (typeof ROOT_FONT_VARIABLES)[number]
const ROOT_FONT_VARIABLE_SET = new Set<string>(ROOT_FONT_VARIABLES)
const FONT_VARIABLES = [
...ROOT_FONT_VARIABLES,
"--font-heading",
] as const
type FontVariable = (typeof FONT_VARIABLES)[number]
const FONT_VARIABLE_SET = new Set<string>(FONT_VARIABLES)
type CssState = {
darkVars: Record<string, string>
imports: string[]
rootVars: Record<string, string>
themeVars: Record<string, string>
}
type NextFontState = {
appliedBodyVariable: RootFontVariable | null
variables: Partial<Record<FontVariable, PresetConfig["font"]>>
}
const RADIUS_MAP: Record<string, PresetConfig["radius"]> = {
"0": "none",
"0rem": "none",
"0.45rem": "small",
"0.625rem": "default",
"0.875rem": "large",
}
export async function resolveProjectPreset(
config: Config,
projectInfo?: ProjectInfo | null
) {
const style = normalizePresetStyle(config.style)
if (!style) {
return { code: null, values: null }
}
const defaults = DEFAULT_PRESETS[style]
const cssState = await readCssState(config.resolvedPaths.tailwindCss)
let resolvedProjectInfo = projectInfo
if (projectInfo === undefined) {
// Most callers already have project info. This keeps the resolver usable
// in isolation without forcing them to fetch it first.
try {
resolvedProjectInfo = await getProjectInfo(config.resolvedPaths.cwd, {
configCssFile: config.tailwind.css,
})
} catch {
resolvedProjectInfo = null
}
}
const nextFonts = await readNextFontState(config, resolvedProjectInfo)
const font = resolveBodyFont(cssState, nextFonts) ?? defaults.font
const fontHeading = normalizeFontHeading(
resolveHeadingFont(cssState, font, nextFonts) ?? defaults.fontHeading,
font,
defaults.fontHeading
)
const values = {
style,
baseColor: asPresetBaseColor(config.tailwind.baseColor) ?? defaults.baseColor,
theme: matchTheme(cssState) ?? defaults.theme,
chartColor: matchChartColor(cssState) ?? defaults.chartColor,
iconLibrary:
asPresetIconLibrary(config.iconLibrary) ?? defaults.iconLibrary,
font,
fontHeading,
radius: matchRadius(cssState.rootVars["--radius"]) ?? defaults.radius,
menuAccent:
asPresetMenuAccent(config.menuAccent) ?? defaults.menuAccent,
menuColor: asPresetMenuColor(config.menuColor) ?? defaults.menuColor,
} satisfies PresetConfig
return {
code: encodePreset(values),
values,
}
}
async function readCssState(tailwindCssPath?: string) {
const fallbackState: CssState = {
darkVars: {},
imports: [],
rootVars: {},
themeVars: {},
}
if (!tailwindCssPath) {
return fallbackState
}
try {
const input = await fs.readFile(tailwindCssPath, "utf8")
return extractCssState(input)
} catch {
return fallbackState
}
}
function normalizePresetStyle(style: string | undefined) {
if (!style) {
return null
}
const normalized = style.replace(/^(base|radix)-/, "")
if (!(normalized in DEFAULT_PRESETS)) {
return null
}
return normalized as keyof typeof DEFAULT_PRESETS
}
function extractCssState(input: string) {
const root = postcss.parse(input)
const state: CssState = {
darkVars: {},
imports: [],
rootVars: {},
themeVars: {},
}
root.walkAtRules("import", (atRule) => {
const source = parseImportSource(atRule.params)
if (source) {
state.imports.push(source)
}
})
root.walkRules((rule) => {
const selectors = rule.selector
.split(",")
.map((selector) => selector.trim())
.filter(Boolean)
if (selectors.includes(":root")) {
collectDeclarations(rule, state.rootVars)
}
if (selectors.includes(".dark")) {
collectDeclarations(rule, state.darkVars)
}
})
root.walkAtRules("theme", (atRule) => {
if (atRule.params.trim() !== "inline") {
return
}
collectDeclarations(atRule, state.themeVars)
})
return state
}
function collectDeclarations(
node: { nodes?: postcss.ChildNode[] },
target: Record<string, string>
) {
for (const child of node.nodes ?? []) {
if (child.type !== "decl" || !child.prop.startsWith("--")) {
continue
}
target[child.prop] = child.value.trim()
}
}
function parseImportSource(params: string) {
const normalized = params.trim()
const match =
normalized.match(/^url\((['"]?)(.+?)\1\)$/) ??
normalized.match(/^(['"])(.+?)\1$/)
return match?.[2] ?? null
}
function matchTheme(state: CssState) {
const lightTheme = matchPresetThemeValue(state.rootVars["--primary"])
if (!lightTheme) {
return null
}
const darkPrimary = state.darkVars["--primary"]
if (!darkPrimary) {
return lightTheme
}
const darkTheme = matchPresetThemeValue(darkPrimary)
return darkTheme === lightTheme ? lightTheme : null
}
function matchChartColor(state: CssState) {
const lightChartColor = matchPresetThemeValue(state.rootVars["--chart-1"])
if (!lightChartColor) {
return null
}
const darkChartColorValue = state.darkVars["--chart-1"]
if (!darkChartColorValue) {
return lightChartColor
}
const darkChartColor = matchPresetThemeValue(darkChartColorValue)
return darkChartColor === lightChartColor ? lightChartColor : null
}
function matchPresetThemeValue(value: string | undefined) {
const family = findTailwindColorFamily(value)
if (!family || !PRESET_THEME_SET.has(family)) {
return null
}
return family as PresetConfig["theme"]
}
function matchRadius(value: string | undefined) {
if (!value) {
return null
}
const normalized = normalizeCssValue(value)
return RADIUS_MAP[normalized] ?? null
}
function resolveBodyFont(state: CssState, nextFonts: NextFontState) {
for (const variable of ROOT_FONT_VARIABLES) {
const matched = matchFontFromVariable(state, variable)
if (matched) {
return matched
}
}
for (const variable of ROOT_FONT_VARIABLES) {
const imported = matchFontByImports(state.imports, variable)
if (imported) {
return imported
}
}
return matchNextBodyFont(nextFonts)
}
function resolveHeadingFont(
state: CssState,
bodyFont: PresetConfig["font"],
nextFonts: NextFontState
) {
const resolved = resolveFontValue(state, "--font-heading")
const matched = resolved ? parseFontFromFamily(resolved) : null
if (matched) {
return matched === bodyFont ? "inherit" : matched
}
const nextHeadingFont = nextFonts.variables["--font-heading"]
const value = getCssVariableValue(state, "--font-heading")
if (!value) {
return nextHeadingFont && nextHeadingFont !== bodyFont
? nextHeadingFont
: null
}
const reference = getVarReference(value)
if (reference && ROOT_FONT_VARIABLE_SET.has(reference)) {
const rootFont = matchFontFromVariable(
state,
reference as RootFontVariable
)
const nextRootFont = nextFonts.variables[reference as RootFontVariable]
const resolvedRootFont = rootFont ?? nextRootFont ?? null
if (!resolvedRootFont || resolvedRootFont === bodyFont) {
return "inherit"
}
return resolvedRootFont
}
if (reference === "--font-heading") {
if (!nextHeadingFont || nextHeadingFont === bodyFont) {
return "inherit"
}
return nextHeadingFont
}
return nextHeadingFont && nextHeadingFont !== bodyFont
? nextHeadingFont
: null
}
function normalizeFontHeading(
fontHeading: PresetConfig["fontHeading"],
bodyFont: PresetConfig["font"],
fallback: PresetConfig["fontHeading"]
) {
const normalized = fontHeading === bodyFont ? "inherit" : fontHeading
return PRESET_FONT_HEADING_SET.has(normalized) ? normalized : fallback
}
function resolveFontValue(
state: CssState,
variable: FontVariable,
seen = new Set<string>()
) {
if (seen.has(variable)) {
return null
}
seen.add(variable)
const value = getCssVariableValue(state, variable)
if (!value) {
return null
}
const reference = getVarReference(value)
if (!reference) {
return value
}
if (FONT_VARIABLE_SET.has(reference)) {
return resolveFontValue(state, reference as FontVariable, seen)
}
return null
}
function getCssVariableValue(state: CssState, variable: FontVariable) {
return state.themeVars[variable] ?? state.rootVars[variable] ?? null
}
function getVarReference(value: string) {
const normalized = normalizeCssValue(value)
const match = normalized.match(/^var\((--[a-z0-9-]+)\)$/)
return match?.[1] ?? null
}
function matchFontFromVariable(state: CssState, variable: RootFontVariable) {
const resolved = resolveFontValue(state, variable)
const matched = resolved ? parseFontFromFamily(resolved) : null
if (matched) {
return matched
}
return null
}
function matchFontByImports(imports: string[], variable: RootFontVariable) {
const matches = imports.flatMap((input) => {
const font = parseFontFromDependency(input)
return font && getFontVariable(font) === variable ? [font] : []
})
return matches.length === 1 ? matches[0] : null
}
function matchNextBodyFont(nextFonts: NextFontState) {
if (
nextFonts.appliedBodyVariable &&
nextFonts.variables[nextFonts.appliedBodyVariable]
) {
return nextFonts.variables[nextFonts.appliedBodyVariable] ?? null
}
const matches = ROOT_FONT_VARIABLES.map(
(variable) => nextFonts.variables[variable]
)
.filter(Boolean)
.filter((font, index, allFonts) => allFonts.indexOf(font) === index) as
PresetConfig["font"][]
return matches.length === 1 ? matches[0] : null
}
async function readNextFontState(
config: Config,
projectInfo: ProjectInfo | null | undefined
) {
const fallbackState: NextFontState = {
appliedBodyVariable: null,
variables: {},
}
if (
!projectInfo ||
(projectInfo.framework.name !== "next-app" &&
projectInfo.framework.name !== "next-pages")
) {
return fallbackState
}
const sourcePath = findNextFontSourceFile(config, projectInfo)
if (!sourcePath) {
return fallbackState
}
try {
const input = await fs.readFile(sourcePath, "utf8")
return extractNextFontState(input, projectInfo.framework.name)
} catch {
return fallbackState
}
}
function findNextFontSourceFile(
config: Config,
projectInfo: ProjectInfo
) {
const ext = projectInfo.isTsx ? "tsx" : "jsx"
const candidates =
projectInfo.framework.name === "next-app"
? projectInfo.isSrcDir
? [`src/app/layout.${ext}`, `app/layout.${ext}`]
: [`app/layout.${ext}`]
: projectInfo.isSrcDir
? [`src/pages/_app.${ext}`, `pages/_app.${ext}`]
: [`pages/_app.${ext}`]
for (const relativePath of candidates) {
const fullPath = path.join(config.resolvedPaths.cwd, relativePath)
if (existsSync(fullPath)) {
return fullPath
}
}
return null
}
function extractNextFontState(
input: string,
framework: ProjectInfo["framework"]["name"]
) {
const project = new Project({
compilerOptions: {},
})
const sourceFile = project.createSourceFile("font-source.tsx", input, {
overwrite: true,
scriptKind: ScriptKind.TSX,
})
const importedFonts = new Map<string, PresetConfig["font"]>()
for (const declaration of sourceFile.getImportDeclarations()) {
if (declaration.getModuleSpecifierValue() !== "next/font/google") {
continue
}
for (const namedImport of declaration.getNamedImports()) {
const importedName = namedImport.getName()
const localName = namedImport.getAliasNode()?.getText() ?? importedName
const font = parseFontFromNextImport(importedName)
if (font) {
importedFonts.set(localName, font)
}
}
}
const variables: NextFontState["variables"] = {}
const declarations = new Map<string, FontVariable>()
for (const statement of sourceFile.getVariableStatements()) {
for (const declaration of statement.getDeclarations()) {
const initializer = declaration.getInitializer()
if (!initializer?.isKind(SyntaxKind.CallExpression)) {
continue
}
const font = importedFonts.get(initializer.getExpression().getText())
if (!font) {
continue
}
const variable = getNextFontVariable(initializer)
if (!variable) {
continue
}
declarations.set(declaration.getName(), variable)
variables[variable] = font
}
}
return {
appliedBodyVariable: getAppliedBodyVariable(sourceFile, declarations, framework),
variables,
}
}
function getNextFontVariable(
callExpression: Node & { getArguments(): Node[] }
) {
const firstArg = callExpression.getArguments()[0]
if (!firstArg || !Node.isObjectLiteralExpression(firstArg)) {
return null
}
const property = firstArg.getProperty("variable")
if (!property || !Node.isPropertyAssignment(property)) {
return null
}
const initializer = property.getInitializer()
if (!initializer) {
return null
}
const variable = stripQuotes(initializer.getText())
if (!FONT_VARIABLE_SET.has(variable)) {
return null
}
return variable as FontVariable
}
function getAppliedBodyVariable(
sourceFile: ReturnType<Project["createSourceFile"]>,
declarations: Map<string, FontVariable>,
framework: ProjectInfo["framework"]["name"]
) {
const elements = sourceFile
.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)
.filter((element) =>
framework === "next-app"
? element.getTagNameNode().getText() === "html"
: true
)
const discovered = new Set<RootFontVariable>()
for (const element of elements) {
const className = element.getAttribute("className")
if (!className || !Node.isJsxAttribute(className)) {
continue
}
const initializer = className.getInitializer()
if (!initializer) {
continue
}
const appliedVariables = getAppliedFontVariables(initializer, declarations)
const utilityVariable = getAppliedBodyUtilityVariable(initializer)
if (
utilityVariable &&
appliedVariables.includes(utilityVariable)
) {
return utilityVariable
}
if (appliedVariables.length === 1) {
discovered.add(appliedVariables[0])
}
}
return discovered.size === 1 ? Array.from(discovered)[0] : null
}
function getAppliedFontVariables(
initializer: Node,
declarations: Map<string, FontVariable>
) {
const expressions = Node.isJsxExpression(initializer)
? [
initializer.getExpression(),
...initializer.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression),
].filter(Boolean)
: []
const variables = new Set<RootFontVariable>()
for (const expression of expressions) {
if (!expression || !Node.isPropertyAccessExpression(expression)) {
continue
}
if (expression.getName() !== "variable") {
continue
}
const target = expression.getExpression().getText()
const variable = declarations.get(target)
if (variable && ROOT_FONT_VARIABLE_SET.has(variable)) {
variables.add(variable as RootFontVariable)
}
}
return Array.from(variables)
}
function getAppliedBodyUtilityVariable(initializer: Node) {
const text = getStringContent(initializer)
if (/\bfont-sans\b/.test(text)) {
return "--font-sans" as const
}
if (/\bfont-serif\b/.test(text)) {
return "--font-serif" as const
}
if (/\bfont-mono\b/.test(text)) {
return "--font-mono" as const
}
return null
}
function getStringContent(node: Node) {
const fragments: string[] = []
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
fragments.push(node.getLiteralValue())
}
for (const literal of node.getDescendantsOfKind(SyntaxKind.StringLiteral)) {
fragments.push(literal.getLiteralValue())
}
for (const literal of node.getDescendantsOfKind(
SyntaxKind.NoSubstitutionTemplateLiteral
)) {
fragments.push(literal.getLiteralValue())
}
return fragments.length > 0 ? fragments.join(" ") : node.getText()
}
function stripQuotes(value: string) {
return value.replace(/^['"]|['"]$/g, "")
}
function parseFontFromFamily(value: string | undefined) {
if (!value) {
return null
}
const primaryFamily = stripQuotes(value.split(",")[0]?.trim() ?? "")
.replace(/\s+variable$/i, "")
.trim()
if (!primaryFamily) {
return null
}
return toPresetFont(primaryFamily.replace(/\s+/g, "-"))
}
function parseFontFromDependency(value: string | undefined) {
if (!value) {
return null
}
const normalized = normalizeCssValue(value)
// Preset font imports use variable fontsource packages.
const prefix = "@fontsource-variable/"
if (!normalized.startsWith(prefix)) {
return null
}
return toPresetFont(normalized.slice(prefix.length))
}
function parseFontFromNextImport(value: string | undefined) {
if (!value) {
return null
}
return toPresetFont(value.replace(/_/g, "-"))
}
function toPresetFont(value: string | undefined) {
const normalized = normalizeCssValue(value)
return PRESET_FONT_SET.has(normalized)
? (normalized as PresetConfig["font"])
: null
}
function getFontVariable(font: PresetConfig["font"]) {
if (MONO_FONTS.has(font)) {
return "--font-mono"
}
if (SERIF_FONTS.has(font)) {
return "--font-serif"
}
return "--font-sans"
}
function normalizeCssValue(value: string | undefined) {
if (!value) {
return ""
}
return value
.trim()
.replace(/\s+/g, " ")
.replace(/\s*,\s*/g, ", ")
.replace(/"/g, "'")
.toLowerCase()
}
function asPresetBaseColor(value: string | undefined) {
return PRESET_BASE_COLOR_SET.has(value ?? "")
? (value as PresetConfig["baseColor"])
: null
}
function asPresetIconLibrary(value: string | undefined) {
return PRESET_ICON_LIBRARY_SET.has(value ?? "")
? (value as PresetConfig["iconLibrary"])
: null
}
function asPresetMenuAccent(value: string | undefined) {
return PRESET_MENU_ACCENT_SET.has(value ?? "")
? (value as PresetConfig["menuAccent"])
: null
}
function asPresetMenuColor(value: string | undefined) {
return PRESET_MENU_COLOR_SET.has(value ?? "")
? (value as PresetConfig["menuColor"])
: null
}

View File

@@ -8,7 +8,10 @@ import {
} from "@/src/schema"
import { Config } from "@/src/utils/get-config"
import { getProjectInfo, ProjectInfo } from "@/src/utils/get-project-info"
import { resolveImport } from "@/src/utils/resolve-import"
import {
isLocalAliasImport,
resolveImportWithMetadata,
} from "@/src/utils/resolve-import"
import {
findCommonRoot,
resolveFilePath,
@@ -119,8 +122,9 @@ export async function recursivelyResolveFileImports(
const moduleSpecifier = importStatement.getModuleSpecifierValue()
const isRelativeImport = moduleSpecifier.startsWith(".")
const isAliasImport = moduleSpecifier.startsWith(
`${projectInfo.aliasPrefix}/`
const isAliasImport = isLocalAliasImport(
moduleSpecifier,
projectInfo.aliasPrefix
)
// If not a local import, add to the dependencies array.
@@ -132,7 +136,12 @@ export async function recursivelyResolveFileImports(
continue
}
let probableImportFilePath = await resolveImport(moduleSpecifier, tsConfig)
let probableImportFilePath = (
await resolveImportWithMetadata(moduleSpecifier, {
...tsConfig,
cwd: config.resolvedPaths.cwd,
})
)?.path
if (isRelativeImport) {
probableImportFilePath = path.resolve(

View File

@@ -0,0 +1,77 @@
import {
deriveAliasFromComponents,
getInitAliasDefaults,
} from "@/src/utils/alias"
import { describe, expect, test } from "vitest"
describe("deriveAliasFromComponents", () => {
test("derives ui aliases from components", () => {
expect(deriveAliasFromComponents("@/components", "ui")).toBe(
"@/components/ui"
)
})
test("derives utils aliases from lib aliases", () => {
expect(deriveAliasFromComponents("#components", "utils")).toBe("#lib/utils")
expect(
deriveAliasFromComponents("#custom/components", "utils", "#custom/lib")
).toBe("#custom/lib/utils")
})
test("derives sibling lib and hooks aliases from components", () => {
expect(deriveAliasFromComponents("@/components", "lib")).toBe("@/lib")
expect(deriveAliasFromComponents("#custom/components", "hooks")).toBe(
"#custom/hooks"
)
})
test("returns an empty string when components alias has no sibling base", () => {
expect(deriveAliasFromComponents("#custom/ui", "lib")).toBe("")
})
})
describe("getInitAliasDefaults", () => {
test("derives standard aliases from components", () => {
expect(getInitAliasDefaults("@/components")).toEqual({
ui: "@/components/ui",
lib: "@/lib",
hooks: "@/hooks",
utils: "@/lib/utils",
})
})
test("derives package import aliases from #components", () => {
expect(getInitAliasDefaults("#components")).toEqual({
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#lib/utils",
})
})
test("derives sibling aliases for nested custom aliases", () => {
expect(getInitAliasDefaults("#custom/components")).toEqual({
ui: "#custom/components/ui",
lib: "#custom/lib",
hooks: "#custom/hooks",
utils: "#custom/lib/utils",
})
})
test("preserves existing aliases when components alias is unchanged", () => {
expect(
getInitAliasDefaults("#components", {
components: "#components",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#lib/utils",
})
).toEqual({
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#lib/utils",
})
})
})

View File

@@ -0,0 +1,62 @@
import type { Config } from "@/src/utils/get-config"
import { DEFAULT_COMPONENTS, DEFAULT_UTILS } from "@/src/utils/get-config"
export function getInitAliasDefaults(
componentsAlias: string,
existingAliases?: Config["aliases"]
) {
// `lib` is the anchor for deriving `utils`, so reuse the existing value first
// when init is re-running against the same components alias.
const derivedLib =
existingAliases?.lib ?? deriveAliasFromComponents(componentsAlias, "lib")
return {
ui: existingAliases?.ui ?? deriveAliasFromComponents(componentsAlias, "ui"),
lib: derivedLib,
hooks:
existingAliases?.hooks ??
deriveAliasFromComponents(componentsAlias, "hooks"),
utils:
existingAliases?.utils ??
deriveAliasFromComponents(componentsAlias, "utils", derivedLib),
}
}
export function deriveAliasFromComponents(
componentsAlias: string,
kind: "ui" | "lib" | "hooks" | "utils",
libAlias?: string
) {
const alias = componentsAlias || DEFAULT_COMPONENTS
if (kind === "ui") {
return `${alias}/ui`
}
if (kind === "utils") {
// `utils` follows `lib`, not `components`, so derive or reuse the sibling
// lib alias before appending `/utils`.
const resolvedLib = libAlias || replaceComponentsAliasTail(alias, "lib")
return resolvedLib ? `${resolvedLib}/utils` : DEFAULT_UTILS
}
return replaceComponentsAliasTail(alias, kind)
}
function replaceComponentsAliasTail(alias: string, kind: "lib" | "hooks") {
// Handles the common `@/components` and `#custom/components` forms by
// swapping the trailing `components` segment for a sibling alias root.
if (alias === "components") {
return kind
}
if (alias.endsWith("/components")) {
return `${alias.slice(0, -"/components".length)}/${kind}`
}
if (alias.endsWith("components") && !alias.includes("/")) {
return `${alias.slice(0, -"components".length)}${kind}`
}
return ""
}

View File

@@ -24,9 +24,13 @@ import { transformCss } from "@/src/utils/updaters/update-css"
import { transformCssVars } from "@/src/utils/updaters/update-css-vars"
import {
findCommonRoot,
getPlannedFilePaths,
resolveFilePath,
rewriteResolvedImportsInContent,
} from "@/src/utils/updaters/update-files"
import { massageTreeForFonts } from "@/src/utils/updaters/update-fonts"
import { Project } from "ts-morph"
import { loadConfig } from "tsconfig-paths"
import type { z } from "zod"
export type DryRunFile = {
@@ -144,6 +148,19 @@ async function processFiles(
? getRegistryBaseColor(config.tailwind.baseColor)
: Promise.resolve(undefined),
])
let tsConfig: ReturnType<typeof loadConfig>
try {
tsConfig = loadConfig(config.resolvedPaths.cwd)
} catch {
tsConfig = { resultType: "failed" } as ReturnType<typeof loadConfig>
}
const project = new Project({
compilerOptions: {},
})
const plannedFilePaths = getPlannedFilePaths(files, config, {
isSrcDir: projectInfo?.isSrcDir,
framework: projectInfo?.framework.name,
})
for (let index = 0; index < files.length; index++) {
const file = files[index]
@@ -203,13 +220,25 @@ async function processFiles(
transformCleanup,
]
)
const finalContent =
isEnvFile(filePath) || isUniversalItemFile
? content
: await rewriteResolvedImportsInContent({
config,
content,
filePaths: plannedFilePaths,
project,
projectInfo,
resolvedPath: filePath,
tsConfig,
})
// Determine action.
let action: DryRunFile["action"] = "create"
let oldContent: string | undefined
if (existingFile) {
oldContent = await fs.readFile(filePath, "utf-8")
if (isContentSame(oldContent, content)) {
if (isContentSame(oldContent, finalContent)) {
action = "skip"
} else {
action = "overwrite"
@@ -219,7 +248,7 @@ async function processFiles(
result.files.push({
path: relativePath,
action,
content,
content: finalContent,
...(action === "overwrite" && { existingContent: oldContent }),
type: file.type ?? "registry:ui",
})

View File

@@ -7,10 +7,10 @@ import {
} from "@/src/schema"
import { getProjectInfo } from "@/src/utils/get-project-info"
import { highlighter } from "@/src/utils/highlighter"
import { resolveImport } from "@/src/utils/resolve-import"
import { resolveImportWithMetadata } from "@/src/utils/resolve-import"
import { cosmiconfig } from "cosmiconfig"
import fg from "fast-glob"
import { loadConfig } from "tsconfig-paths"
import { loadConfig, type ConfigLoaderSuccessResult } from "tsconfig-paths"
import { z } from "zod"
export const DEFAULT_STYLE = "default"
@@ -64,6 +64,37 @@ export async function resolveConfigPaths(
)
}
// Resolve the primary aliases first so fallbacks can reuse their results.
const resolvedUtils = await resolveAliasPath(
"utils",
config.aliases["utils"],
cwd,
tsConfig
)
const resolvedComponents = await resolveAliasPath(
"components",
config.aliases["components"],
cwd,
tsConfig
)
const resolvedUi = config.aliases["ui"]
? await resolveAliasPath("ui", config.aliases["ui"], cwd, tsConfig)
: path.resolve(resolvedComponents ?? cwd, "ui")
const resolvedLib = config.aliases["lib"]
? await resolveAliasPath("lib", config.aliases["lib"], cwd, tsConfig)
: path.resolve(resolvedUtils ?? cwd, "..")
const resolvedHooks = config.aliases["hooks"]
? await resolveAliasPath("hooks", config.aliases["hooks"], cwd, tsConfig)
: path.resolve(resolvedComponents ?? cwd, "..", "hooks")
assertResolvedAliases(cwd, {
components: resolvedComponents,
utils: resolvedUtils,
ui: resolvedUi,
lib: resolvedLib,
hooks: resolvedHooks,
})
return configSchema.parse({
...config,
resolvedPaths: {
@@ -72,35 +103,93 @@ export async function resolveConfigPaths(
? path.resolve(cwd, config.tailwind.config)
: "",
tailwindCss: path.resolve(cwd, config.tailwind.css),
utils: await resolveImport(config.aliases["utils"], tsConfig),
components: await resolveImport(config.aliases["components"], tsConfig),
ui: config.aliases["ui"]
? await resolveImport(config.aliases["ui"], tsConfig)
: path.resolve(
(await resolveImport(config.aliases["components"], tsConfig)) ??
cwd,
"ui"
),
utils: resolvedUtils,
components: resolvedComponents,
ui: resolvedUi,
// TODO: Make this configurable.
// For now, we assume the lib and hooks directories are one level up from the components directory.
lib: config.aliases["lib"]
? await resolveImport(config.aliases["lib"], tsConfig)
: path.resolve(
(await resolveImport(config.aliases["utils"], tsConfig)) ?? cwd,
".."
),
hooks: config.aliases["hooks"]
? await resolveImport(config.aliases["hooks"], tsConfig)
: path.resolve(
(await resolveImport(config.aliases["components"], tsConfig)) ??
cwd,
"..",
"hooks"
),
lib: resolvedLib,
hooks: resolvedHooks,
},
})
}
async function resolveAliasPath(
aliasKey: "components" | "utils" | "ui" | "lib" | "hooks",
alias: string,
cwd: string,
tsConfig: Pick<ConfigLoaderSuccessResult, "absoluteBaseUrl" | "paths">
) {
const resolved = await resolveImportWithMetadata(alias, {
...tsConfig,
cwd,
})
if (!resolved?.path) {
return null
}
if (alias.startsWith("#") && resolved.path === path.resolve(cwd, alias)) {
return null
}
// For non-utils alias keys backed by package imports or workspace exports,
// strip directory-level artifacts so the resolved path points at the
// directory root rather than a specific file.
if (
aliasKey !== "utils" &&
(resolved.source === "package_imports" ||
resolved.source === "workspace_package_exports")
) {
// Exact aliases (e.g. `#hooks` → `./src/hooks/index.ts`) should resolve
// to the directory root.
if (
!resolved.matchedAlias.includes("*") &&
/\/index\.[^/]+$/.test(resolved.path)
) {
return path.dirname(resolved.path)
}
// Wildcard aliases with explicit extensions (e.g. `#components/*` →
// `./src/components/*.tsx`) should strip the source extension so `ui`
// resolves to `/src/components/ui` instead of `/src/components/ui.tsx`.
if (resolved.matchedAlias.includes("*") && /\.[^/]+$/.test(resolved.path)) {
return resolved.path.replace(/\.[^/]+$/, "")
}
}
return resolved.path
}
function assertResolvedAliases(
cwd: string,
resolvedAliases: Record<
"components" | "utils" | "ui" | "lib" | "hooks",
string | null
>
) {
const missingAliases = ["components", "ui", "lib", "hooks", "utils"].filter(
(key) => !resolvedAliases[key as keyof typeof resolvedAliases]
)
if (!missingAliases.length) {
return
}
throw new Error(
[
`Could not resolve the following aliases in ${highlighter.info(cwd)}: ${highlighter.info(
missingAliases.join(", ")
)}.`,
`Configure path aliases in ${highlighter.info(
"tsconfig.json"
)} or imports in ${highlighter.info(
"package.json"
)} for this workspace and try again.`,
].join("\n")
)
}
export async function getRawConfig(
cwd: string
): Promise<z.infer<typeof rawConfigSchema> | null> {
@@ -158,7 +247,20 @@ export async function getWorkspaceConfig(config: Config) {
continue
}
resolvedAliases[key] = await getConfig(packageRoot)
const workspaceConfig = await getConfig(packageRoot)
if (!workspaceConfig) {
throw new Error(
[
`Could not load the workspace config in ${highlighter.info(packageRoot)}.`,
`Add ${highlighter.info(
"components.json"
)} to this workspace and configure its path aliases or package imports, then try again.`,
].join("\n")
)
}
resolvedAliases[key] = workspaceConfig
}
const result = workspaceConfigSchema.safeParse(resolvedAliases)

View File

@@ -127,7 +127,7 @@ export function formatMonorepoMessage(
logger.break()
}
async function getWorkspacePatterns(cwd: string) {
export async function getWorkspacePatterns(cwd: string) {
const patterns: string[] = []
// Read pnpm-workspace.yaml.

View File

@@ -6,6 +6,10 @@ import { rawConfigSchema } from "@/src/schema"
import { Framework, FRAMEWORKS } from "@/src/utils/frameworks"
import { Config, getConfig, resolveConfigPaths } from "@/src/utils/get-config"
import { getPackageInfo } from "@/src/utils/get-package-info"
import {
getPackageImportAliases,
getPackageImportPrefix,
} from "@/src/utils/package-imports"
import fg from "fast-glob"
import fs from "fs-extra"
import { loadConfig } from "tsconfig-paths"
@@ -50,7 +54,7 @@ export async function getProjectInfo(
tailwindConfigFile,
tailwindCssFile,
tailwindVersion,
aliasPrefix,
aliasPrefixInfo,
packageJson,
] = await Promise.all([
fg.glob(
@@ -66,7 +70,7 @@ export async function getProjectInfo(
getTailwindConfigFile(cwd),
getTailwindCssFile(cwd, opts?.configCssFile),
getTailwindVersion(cwd),
getTsConfigAliasPrefix(cwd),
getProjectAliasInfo(cwd),
getPackageInfo(cwd, false),
])
@@ -83,7 +87,7 @@ export async function getProjectInfo(
tailwindCssFile,
tailwindVersion,
frameworkVersion: null,
aliasPrefix,
aliasPrefix: aliasPrefixInfo.prefix,
}
// Next.js.
@@ -300,28 +304,62 @@ export async function getTailwindConfigFile(cwd: string) {
export async function getTsConfigAliasPrefix(cwd: string) {
const tsConfig = await loadConfig(cwd)
const paths =
tsConfig?.resultType === "success" && Object.entries(tsConfig.paths).length
? tsConfig.paths
: (await getTsConfig(cwd))?.compilerOptions.paths
if (
tsConfig?.resultType === "failed" ||
!Object.entries(tsConfig?.paths).length
) {
if (!paths || !Object.entries(paths).length) {
return null
}
// This assume that the first alias is the prefix.
for (const [alias, paths] of Object.entries(tsConfig.paths)) {
for (const [alias, targets] of Object.entries(paths)) {
const values = Array.isArray(targets) ? targets : [targets]
if (
paths.includes("./*") ||
paths.includes("./src/*") ||
paths.includes("./app/*") ||
paths.includes("./resources/js/*") // Laravel.
values.includes("./*") ||
values.includes("./src/*") ||
values.includes("./app/*") ||
values.includes("./resources/js/*") // Laravel.
) {
return alias.replace(/\/\*$/, "") ?? null
}
}
// Use the first alias as the prefix.
return Object.keys(tsConfig?.paths)?.[0].replace(/\/\*$/, "") ?? null
return Object.keys(paths)?.[0].replace(/\/\*$/, "") ?? null
}
export async function getProjectAliasInfo(cwd: string) {
const tsConfigAliasPrefix = await getTsConfigAliasPrefix(cwd)
const packageImportPrefix = getPackageImportPrefix(cwd)
if (packageImportPrefix && tsConfigAliasPrefix?.startsWith("#")) {
return {
prefix: packageImportPrefix,
source: "package_imports" as const,
}
}
if (tsConfigAliasPrefix) {
return {
prefix: tsConfigAliasPrefix,
source: "tsconfig_paths" as const,
}
}
if (packageImportPrefix) {
return {
prefix: packageImportPrefix,
source: "package_imports" as const,
}
}
return {
prefix: null,
source: null,
}
}
export async function isTypeScriptProject(cwd: string) {
@@ -345,10 +383,16 @@ export async function getTsConfig(cwd: string) {
continue
}
// We can't use fs.readJSON because it doesn't support comments.
const contents = await fs.readFile(filePath, "utf8")
const cleanedContents = contents.replace(/\/\*\s*\*\//g, "")
const result = TS_CONFIG_SCHEMA.safeParse(JSON.parse(cleanedContents))
let parsed
try {
parsed = JSON.parse(stripJsonComments(contents))
} catch {
continue
}
const result = TS_CONFIG_SCHEMA.safeParse(parsed)
if (result.error) {
continue
@@ -360,16 +404,89 @@ export async function getTsConfig(cwd: string) {
return null
}
function stripJsonComments(value: string) {
let result = ""
let inString = false
let escaped = false
for (let index = 0; index < value.length; index++) {
const current = value[index]
const next = value[index + 1]
if (inString) {
result += current
if (escaped) {
escaped = false
continue
}
if (current === "\\") {
escaped = true
continue
}
if (current === '"') {
inString = false
}
continue
}
if (current === '"') {
inString = true
result += current
continue
}
if (current === "/" && next === "/") {
while (index < value.length && value[index] !== "\n") {
index++
}
if (index < value.length) {
result += value[index]
}
continue
}
if (current === "/" && next === "*") {
index += 2
while (index < value.length) {
if (value[index] === "*" && value[index + 1] === "/") {
index++
break
}
if (value[index] === "\n") {
result += "\n"
}
index++
}
continue
}
result += current
}
return result
}
export async function getProjectConfig(
cwd: string,
defaultProjectInfo: ProjectInfo | null = null
): Promise<Config | null> {
// Check for existing component config.
const [existingConfig, projectInfo] = await Promise.all([
const [existingConfig, projectInfo, aliasInfo] = await Promise.all([
getConfig(cwd),
!defaultProjectInfo
? getProjectInfo(cwd)
: Promise.resolve(defaultProjectInfo),
getProjectAliasInfo(cwd),
])
if (existingConfig) {
@@ -384,6 +501,35 @@ export async function getProjectConfig(
return null
}
const packageImportAliases =
aliasInfo.source === "package_imports" ? getPackageImportAliases(cwd) : null
if (!projectInfo.aliasPrefix) {
return null
}
const fallbackAliases = getAliasDefaultsFromPrefix(
projectInfo.aliasPrefix,
aliasInfo.source === "package_imports"
)
const aliases =
aliasInfo.source === "package_imports" && packageImportAliases
? derivePackageImportAliases({
...fallbackAliases,
components:
packageImportAliases.components ?? fallbackAliases.components,
ui: packageImportAliases.ui ?? fallbackAliases.ui,
hooks: packageImportAliases.hooks ?? fallbackAliases.hooks,
lib: packageImportAliases.lib ?? fallbackAliases.lib,
utils: packageImportAliases.utils ?? fallbackAliases.utils,
})
: fallbackAliases
if (!aliases.components || !aliases.utils) {
return null
}
const config: z.infer<typeof rawConfigSchema> = {
$schema: "https://ui.shadcn.com/schema.json",
rsc: projectInfo.isRSC,
@@ -397,18 +543,59 @@ export async function getProjectConfig(
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: `${projectInfo.aliasPrefix}/components`,
ui: `${projectInfo.aliasPrefix}/components/ui`,
hooks: `${projectInfo.aliasPrefix}/hooks`,
lib: `${projectInfo.aliasPrefix}/lib`,
utils: `${projectInfo.aliasPrefix}/lib/utils`,
},
aliases,
}
return await resolveConfigPaths(cwd, config)
}
function getAliasDefaultsFromPrefix(
aliasPrefix: string,
isPackageImport: boolean = false
) {
if (isPackageImport && aliasPrefix === "#") {
return {
components: "",
ui: undefined,
hooks: undefined,
lib: undefined,
utils: "",
}
}
return {
components: `${aliasPrefix}/components`,
ui: `${aliasPrefix}/components/ui`,
hooks: `${aliasPrefix}/hooks`,
lib: `${aliasPrefix}/lib`,
utils: `${aliasPrefix}/lib/utils`,
}
}
function derivePackageImportAliases(aliases: {
components: string
ui?: string
hooks?: string
lib?: string
utils: string
}) {
const derivedAliases = { ...aliases }
if (!derivedAliases.ui && derivedAliases.components) {
derivedAliases.ui = `${derivedAliases.components}/ui`
}
if (!derivedAliases.lib && derivedAliases.utils.endsWith("/utils")) {
derivedAliases.lib = derivedAliases.utils.slice(0, -"/utils".length)
}
if (!derivedAliases.utils && derivedAliases.lib) {
derivedAliases.utils = `${derivedAliases.lib}/utils`
}
return derivedAliases
}
export async function getProjectTailwindVersionFromConfig(config: {
resolvedPaths: Pick<Config["resolvedPaths"], "cwd">
}): Promise<TailwindVersion> {

View File

@@ -0,0 +1,156 @@
import path from "path"
// Node can resolve `package.json#imports` and `package.json#exports` at
// runtime, but the CLI needs the matched pattern, local filesystem target, and
// emit behavior as data so it can place files and rewrite imports consistently.
// This module is the shared matcher for those normalized entry shapes.
export type ImportEmitMode = "strip_extension" | "preserve_extension"
export type ImportResolutionEntry = {
key: string
aliasBase: string
target: string
emitMode: ImportEmitMode
hasWildcard: boolean
rootDir: string
}
export type ImportResolutionMatch = {
path: string
matchedAlias: string
matchedTarget: string
emitMode: ImportEmitMode
}
export function resolveLocalPathTarget(target: unknown) {
const queue = [target]
while (queue.length) {
const value = queue.shift()
if (typeof value === "string") {
if (value.startsWith(".")) {
return value
}
continue
}
if (Array.isArray(value)) {
queue.unshift(...value)
continue
}
if (value && typeof value === "object") {
queue.unshift(...Object.values(value as Record<string, unknown>))
}
}
return null
}
export function getImportTargetEmitMode(target: string) {
if (!target.includes("*")) {
return "strip_extension"
}
const suffix = target.slice(target.indexOf("*") + 1)
// A bare `*` target like `./src/components/*` expects the emitted specifier
// to include the source extension (`#components/button.tsx`).
if (!suffix) {
return "preserve_extension"
}
return /^\.[^/]+$/.test(suffix) ? "strip_extension" : "preserve_extension"
}
export function resolveImportEntryMatch(
importPath: string,
entries: ImportResolutionEntry[]
) {
const exactMatch = entries.find(
(entry) => !entry.hasWildcard && entry.key === importPath
)
if (exactMatch) {
return {
path: path.resolve(exactMatch.rootDir, exactMatch.target),
matchedAlias: exactMatch.key,
matchedTarget: exactMatch.target,
emitMode: exactMatch.emitMode,
}
}
const wildcardMatches = entries
.filter((entry) => entry.hasWildcard)
.sort((a, b) => b.key.length - a.key.length)
for (const entry of wildcardMatches) {
const wildcardValue = getPatternWildcardValue(importPath, entry.key, {
allowBareAliasBase: true,
})
if (wildcardValue === null) {
continue
}
return {
path: path.resolve(
entry.rootDir,
applyWildcardTarget(entry.target, wildcardValue)
),
matchedAlias: entry.key,
matchedTarget: entry.target,
emitMode: entry.emitMode,
}
}
return null
}
export function getPatternWildcardValue(
importPath: string,
pattern: string,
options: {
allowBareAliasBase?: boolean
} = {}
) {
if (!pattern.includes("*")) {
return importPath === pattern ? "" : null
}
const [prefix, suffix = ""] = pattern.split("*")
if (importPath.startsWith(prefix) && importPath.endsWith(suffix)) {
return suffix
? importPath.slice(prefix.length, -suffix.length)
: importPath.slice(prefix.length)
}
if (
options.allowBareAliasBase &&
suffix === "" &&
prefix.endsWith("/") &&
importPath === prefix.slice(0, -1)
) {
return ""
}
return null
}
export function applyWildcardTarget(target: string, wildcardValue: string) {
if (!target.includes("*")) {
return target
}
const [prefix, suffix = ""] = target.split("*")
if (!wildcardValue) {
return prefix.replace(/\/$/, "")
}
return `${prefix}${wildcardValue}${suffix}`
}

View File

@@ -0,0 +1,185 @@
import path from "path"
import { getPackageInfo } from "@/src/utils/get-package-info"
import {
getImportTargetEmitMode,
resolveImportEntryMatch,
resolveLocalPathTarget,
type ImportEmitMode,
type ImportResolutionEntry,
type ImportResolutionMatch,
} from "@/src/utils/import-matcher"
export type { ImportEmitMode } from "@/src/utils/import-matcher"
export type PackageImportEntry = ImportResolutionEntry
export type PackageImportMatch = ImportResolutionMatch
const packageImportEntriesCache = new Map<string, PackageImportEntry[]>()
export function getPackageImportEntries(cwd: string) {
const cacheKey = path.resolve(cwd)
const cachedEntries = packageImportEntriesCache.get(cacheKey)
if (cachedEntries) {
return cachedEntries
}
const packageInfo = getPackageInfo(cwd, false)
const imports = packageInfo?.imports
if (!imports || typeof imports !== "object" || Array.isArray(imports)) {
packageImportEntriesCache.set(cacheKey, [])
return []
}
const entries: PackageImportEntry[] = []
for (const [key, value] of Object.entries(imports)) {
if (!key.startsWith("#")) {
continue
}
const target = resolveLocalPathTarget(value)
if (!target) {
continue
}
entries.push({
key,
aliasBase:
key === "#*" ? "#" : key.endsWith("/*") ? key.slice(0, -2) : key,
target,
emitMode: getImportTargetEmitMode(target),
hasWildcard: key.includes("*"),
rootDir: cacheKey,
})
}
packageImportEntriesCache.set(cacheKey, entries)
return entries
}
export function getPackageImportPrefix(cwd: string) {
const aliases = getPackageImportEntries(cwd).map((entry) => entry.aliasBase)
if (!aliases.length) {
return null
}
return getSharedPackageImportPrefix(aliases)
}
export function resolvePackageImport(importPath: string, cwd: string) {
return resolveImportEntryMatch(importPath, getPackageImportEntries(cwd))
}
export function getPackageImportAliases(cwd: string) {
const entries = getPackageImportEntries(cwd)
const rootWildcardDefaults = entries.some((entry) => entry.key === "#*")
? {
components: "#components",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#lib/utils",
}
: null
return {
components:
findBestAlias(entries, "components") ?? rootWildcardDefaults?.components,
ui: findBestAlias(entries, "ui") ?? rootWildcardDefaults?.ui,
lib: findBestAlias(entries, "lib") ?? rootWildcardDefaults?.lib,
hooks: findBestAlias(entries, "hooks") ?? rootWildcardDefaults?.hooks,
utils: findBestAlias(entries, "utils") ?? rootWildcardDefaults?.utils,
}
}
function findBestAlias(
entries: PackageImportEntry[],
kind: "components" | "ui" | "lib" | "hooks" | "utils"
) {
const matches = entries
.map((entry) => ({
entry,
score: getAliasScore(entry, kind),
}))
.filter((match) => match.score > 0)
.sort(
(a, b) =>
b.score - a.score || b.entry.aliasBase.length - a.entry.aliasBase.length
)
return matches[0]?.entry.aliasBase
}
function getAliasScore(
entry: PackageImportEntry,
kind: "components" | "ui" | "lib" | "hooks" | "utils"
) {
const aliasBase = entry.aliasBase.toLowerCase()
const normalizedTarget = normalizeTarget(entry.target).toLowerCase()
switch (kind) {
case "components":
if (
aliasBase.endsWith("/ui") ||
normalizedTarget.includes("/components/ui")
) {
return 0
}
if (includesPathSegment(aliasBase, "components")) return 4
if (includesPathSegment(normalizedTarget, "components")) return 3
return 0
case "ui":
if (aliasBase.endsWith("/ui") || aliasBase === "#ui") return 5
if (normalizedTarget.includes("/components/ui")) return 4
if (normalizedTarget.endsWith("/ui")) return 3
return 0
case "lib":
if (aliasBase === "#lib" || aliasBase.endsWith("/lib")) return 5
if (normalizedTarget.endsWith("/lib")) return 4
if (includesPathSegment(normalizedTarget, "lib")) return 3
return 0
case "hooks":
if (aliasBase === "#hooks" || aliasBase.endsWith("/hooks")) return 5
if (normalizedTarget.endsWith("/hooks")) return 4
if (includesPathSegment(normalizedTarget, "hooks")) return 3
return 0
case "utils":
if (aliasBase === "#utils" || aliasBase.endsWith("/utils")) return 5
if (normalizedTarget.endsWith("/lib/utils")) return 4
if (normalizedTarget.endsWith("/utils")) return 3
return 0
}
}
function normalizeTarget(target: string) {
return target
.replace(/\/\*$/, "")
.replace(/\*$/, "")
.replace(/\/index\.[^/]+$/, "")
}
function includesPathSegment(value: string, segment: string) {
return (
value === segment ||
value.includes(`/${segment}`) ||
value.includes(`${segment}/`)
)
}
function getSharedPackageImportPrefix(aliasBases: string[]) {
const sharedSegments = aliasBases
.map((aliasBase) => aliasBase.slice(1).split("/").filter(Boolean))
.reduce<string[]>((shared, segments, index) => {
if (!index) {
return segments
}
return shared.filter((segment, segmentIndex) => {
return segments[segmentIndex] === segment
})
}, [])
return sharedSegments.length ? `#${sharedSegments.join("/")}` : "#"
}

View File

@@ -1,13 +1,139 @@
import { getPatternWildcardValue } from "@/src/utils/import-matcher"
import {
resolvePackageImport,
type ImportEmitMode,
} from "@/src/utils/package-imports"
import { resolveWorkspacePackageExport } from "@/src/utils/workspace"
import { createMatchPath, type ConfigLoaderSuccessResult } from "tsconfig-paths"
export type ResolvedImport = {
path: string
source: "tsconfig_paths" | "package_imports" | "workspace_package_exports"
matchedAlias: string
matchedTarget: string
emitMode: ImportEmitMode
}
type ResolveImportConfig = Pick<
ConfigLoaderSuccessResult,
"absoluteBaseUrl" | "paths"
> & {
cwd?: string
}
export async function resolveImportWithMetadata(
importPath: string,
config: ResolveImportConfig
) {
const cwd = config.cwd ?? config.absoluteBaseUrl
if (importPath.startsWith("#")) {
const resolved = resolvePackageImport(importPath, cwd)
if (resolved) {
return {
path: resolved.path,
source: "package_imports",
matchedAlias: resolved.matchedAlias,
matchedTarget: resolved.matchedTarget,
emitMode: resolved.emitMode,
} satisfies ResolvedImport
}
}
const workspaceResolved = await resolveWorkspacePackageExport(importPath, cwd)
if (workspaceResolved) {
return {
path: workspaceResolved.path,
source: "workspace_package_exports",
matchedAlias: workspaceResolved.matchedAlias,
matchedTarget: workspaceResolved.matchedTarget,
emitMode: workspaceResolved.emitMode,
} satisfies ResolvedImport
}
return resolveFromTsconfigPaths(importPath, config)
}
export async function resolveImport(
importPath: string,
config: Pick<ConfigLoaderSuccessResult, "absoluteBaseUrl" | "paths">
config: ResolveImportConfig
) {
return createMatchPath(config.absoluteBaseUrl, config.paths)(
return (await resolveImportWithMetadata(importPath, config))?.path ?? null
}
export function isLocalAliasImport(
moduleSpecifier: string,
aliasPrefix: string | null
) {
// Workspace package exports such as `@workspace/ui/...` are already the final
// import specifiers we want to keep, so they are intentionally excluded here.
if (moduleSpecifier.startsWith("#")) {
return true
}
if (!aliasPrefix) {
return false
}
return moduleSpecifier.startsWith(`${aliasPrefix}/`)
}
function isScopedPackageSpecifier(importPath: string) {
return /^@[^/]+\/[^/]+(?:\/.*)?$/.test(importPath)
}
function resolveFromTsconfigPaths(
importPath: string,
config: ResolveImportConfig
) {
const matchedPath = createMatchPath(config.absoluteBaseUrl, config.paths)(
importPath,
undefined,
() => true,
[".ts", ".tsx", ".jsx", ".js", ".css"]
)
if (!matchedPath) {
return null
}
const matchedPattern = findMatchingTsPathPattern(importPath, config.paths)
if (!matchedPattern && isScopedPackageSpecifier(importPath)) {
return null
}
return {
path: matchedPath,
source: "tsconfig_paths",
matchedAlias: matchedPattern?.key ?? importPath,
matchedTarget: matchedPattern?.target ?? matchedPath,
emitMode: "strip_extension",
}
}
function findMatchingTsPathPattern(
importPath: string,
paths: ConfigLoaderSuccessResult["paths"]
) {
for (const [key, targets] of Object.entries(paths)) {
const targetList = Array.isArray(targets) ? targets : [targets]
const wildcardValue = getPatternWildcardValue(importPath, key)
if (wildcardValue === null) {
continue
}
return {
key,
target:
targetList[0]?.includes("*") && wildcardValue !== null
? targetList[0].replace(/\*/g, wildcardValue)
: targetList[0],
}
}
return null
}

View File

@@ -9,10 +9,12 @@ export const transformImport: Transformer = async ({
}) => {
const utilsAlias = config.aliases?.utils
const workspaceAlias =
typeof utilsAlias === "string" && utilsAlias.includes("/")
? utilsAlias.split("/")[0]
typeof utilsAlias === "string"
? getWorkspaceAliasFromUtilsAlias(utilsAlias)
: "@"
const utilsImport = `${workspaceAlias}/lib/utils`
const utilsImport = workspaceAlias
? `${workspaceAlias}/lib/utils`
: "@/lib/utils"
if (![".tsx", ".ts", ".jsx", ".js"].includes(sourceFile.getExtension())) {
return sourceFile
@@ -55,6 +57,8 @@ function updateImportAliases(
config: Config,
isRemote: boolean = false
) {
moduleSpecifier = normalizeImportSpecifier(moduleSpecifier, config)
// Not a local import.
if (!moduleSpecifier.startsWith("@/") && !isRemote) {
return moduleSpecifier
@@ -65,9 +69,41 @@ function updateImportAliases(
moduleSpecifier = moduleSpecifier.replace(/^@\//, `@/registry/new-york/`)
}
if (moduleSpecifier === "@/registry") {
return config.aliases.components
}
// Not a registry import.
if (!moduleSpecifier.startsWith("@/registry/")) {
// We fix the alias and return.
if (moduleSpecifier === "@/lib/utils" && config.aliases.utils) {
return config.aliases.utils
}
if (
config.aliases.ui &&
moduleSpecifier.match(/^@\/components\/ui(?=\/|$)/)
) {
return moduleSpecifier.replace(/^@\/components\/ui/, config.aliases.ui)
}
if (
config.aliases.components &&
moduleSpecifier.match(/^@\/components(?=\/|$)/)
) {
return moduleSpecifier.replace(
/^@\/components/,
config.aliases.components
)
}
if (config.aliases.hooks && moduleSpecifier.match(/^@\/hooks(?=\/|$)/)) {
return moduleSpecifier.replace(/^@\/hooks/, config.aliases.hooks)
}
if (config.aliases.lib && moduleSpecifier.match(/^@\/lib(?=\/|$)/)) {
return moduleSpecifier.replace(/^@\/lib/, config.aliases.lib)
}
const alias = config.aliases.components.split("/")[0]
return moduleSpecifier.replace(/^@\//, `${alias}/`)
}
@@ -79,6 +115,13 @@ function updateImportAliases(
)
}
if (
config.aliases.utils &&
moduleSpecifier.match(/^@\/registry\/(.+)\/lib\/utils$/)
) {
return config.aliases.utils
}
if (
config.aliases.components &&
moduleSpecifier.match(/^@\/registry\/(.+)\/components/)
@@ -111,3 +154,71 @@ function updateImportAliases(
config.aliases.components
)
}
function getWorkspaceAliasFromUtilsAlias(utilsAlias: string) {
// `#...` utils aliases are handled by package-import normalization and should
// not be treated as workspace package roots.
if (utilsAlias.startsWith("#")) {
return ""
}
if (utilsAlias.endsWith("/lib/utils")) {
return utilsAlias.slice(0, -"/lib/utils".length)
}
if (utilsAlias.startsWith("@")) {
const [scope, name] = utilsAlias.split("/")
return scope && name ? `${scope}/${name}` : utilsAlias
}
const slashIndex = utilsAlias.indexOf("/")
return slashIndex === -1 ? utilsAlias : utilsAlias.slice(0, slashIndex)
}
function normalizeImportSpecifier(moduleSpecifier: string, config: Config) {
if (moduleSpecifier === "#registry") {
return "@/registry"
}
if (moduleSpecifier.startsWith("#/")) {
return moduleSpecifier.replace(/^#\//, "@/")
}
if (moduleSpecifier.startsWith("#registry/")) {
return moduleSpecifier.replace(/^#registry\//, "@/registry/")
}
// We only normalize the standard shadcn alias slots here so the rest of the
// transformer can keep operating on the canonical `@/...` forms it already
// understands.
for (const { alias, normalized } of getConfigAliasNormalizations(config)) {
if (moduleSpecifier === alias) {
return normalized
}
if (moduleSpecifier.startsWith(`${alias}/`)) {
return `${normalized}${moduleSpecifier.slice(alias.length)}`
}
}
return moduleSpecifier
}
function getConfigAliasNormalizations(config: Config) {
if (!config.aliases) {
return []
}
return [
{ alias: config.aliases.ui, normalized: "@/components/ui" },
{ alias: config.aliases.components, normalized: "@/components" },
{ alias: config.aliases.hooks, normalized: "@/hooks" },
{ alias: config.aliases.lib, normalized: "@/lib" },
{ alias: config.aliases.utils, normalized: "@/lib/utils" },
]
.filter(
(entry): entry is { alias: string; normalized: string } =>
typeof entry.alias === "string" && entry.alias.startsWith("#")
)
.sort((a, b) => b.alias.length - a.alias.length)
}

View File

@@ -15,7 +15,11 @@ import { Config } from "@/src/utils/get-config"
import { getProjectInfo, ProjectInfo } from "@/src/utils/get-project-info"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { resolveImport } from "@/src/utils/resolve-import"
import { resolvePackageImport } from "@/src/utils/package-imports"
import {
isLocalAliasImport,
resolveImportWithMetadata,
} from "@/src/utils/resolve-import"
import { spinner } from "@/src/utils/spinner"
import { transform } from "@/src/utils/transformers"
import { transformAsChild } from "@/src/utils/transformers/transform-aschild"
@@ -31,9 +35,11 @@ import { transformRtl } from "@/src/utils/transformers/transform-rtl"
import { transformTwPrefixes } from "@/src/utils/transformers/transform-tw-prefix"
import prompts from "prompts"
import { Project, ScriptKind } from "ts-morph"
import { loadConfig } from "tsconfig-paths"
import { loadConfig, type ConfigLoaderSuccessResult } from "tsconfig-paths"
import { z } from "zod"
const CODE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"]
export async function updateFiles(
files: RegistryItem["files"],
config: Config,
@@ -73,6 +79,15 @@ export async function updateFiles(
? getRegistryBaseColor(config.tailwind.baseColor)
: Promise.resolve(undefined),
])
const tsConfig = loadConfig(config.resolvedPaths.cwd)
const plannedFilePaths = getPlannedFilePaths(files, config, {
isSrcDir: projectInfo?.isSrcDir,
framework: projectInfo?.framework.name,
path: options.path,
})
const importRewriteProject = new Project({
compilerOptions: {},
})
let filesCreated: string[] = []
let filesUpdated: string[] = []
@@ -176,10 +191,19 @@ export async function updateFiles(
// Skip the file if it already exists and the content is the same.
// Exception: Don't skip .env files as we merge content instead of replacing
if (existingFile && !isEnvFile(filePath)) {
const resolvedContent = await rewriteResolvedImportsInContent({
config,
content,
filePaths: plannedFilePaths,
project: importRewriteProject,
projectInfo,
resolvedPath: filePath,
tsConfig,
})
const existingFileContent = await fs.readFile(filePath, "utf-8")
if (
isContentSame(existingFileContent, content, {
isContentSame(existingFileContent, resolvedContent, {
// Ignore import differences for workspace components.
// TODO: figure out if we always want this.
ignoreImports: options.isWorkspace,
@@ -554,66 +578,175 @@ async function resolveImports(filePaths: string[], config: Config) {
if (![".tsx", ".ts", ".jsx", ".js"].includes(sourceFile.getExtension())) {
continue
}
const rewrittenContent = await rewriteResolvedImportsInContent({
config,
content,
filePaths,
project,
projectInfo,
resolvedPath,
sourceFile,
tsConfig,
})
const importDeclarations = sourceFile.getImportDeclarations()
for (const importDeclaration of importDeclarations) {
const moduleSpecifier = importDeclaration.getModuleSpecifierValue()
// Filter out non-local imports.
if (
projectInfo?.aliasPrefix &&
!moduleSpecifier.startsWith(`${projectInfo.aliasPrefix}/`)
) {
continue
}
// Find the probable import file path.
// This is where we expect to find the file on disk.
const probableImportFilePath = await resolveImport(
moduleSpecifier,
tsConfig
)
if (!probableImportFilePath) {
continue
}
// Find the actual import file path.
// This is the path where the file has been installed.
const resolvedImportFilePath = resolveModuleByProbablePath(
probableImportFilePath,
filePaths,
config
)
if (!resolvedImportFilePath) {
continue
}
// Convert the resolved import file path to an aliased import.
const newImport = toAliasedImport(
resolvedImportFilePath,
config,
projectInfo
)
if (!newImport || newImport === moduleSpecifier) {
continue
}
importDeclaration.setModuleSpecifier(newImport)
// Write the updated content to the file.
await fs.writeFile(resolvedPath, sourceFile.getFullText(), "utf-8")
// Track the updated file.
updatedFiles.push(filepath)
if (rewrittenContent === content) {
continue
}
await fs.writeFile(resolvedPath, rewrittenContent, "utf-8")
updatedFiles.push(filepath)
}
return updatedFiles
}
export function getPlannedFilePaths(
files: RegistryItem["files"],
config: Config,
options: {
isSrcDir?: boolean
framework?: ProjectInfo["framework"]["name"]
path?: string
}
) {
return (files ?? [])
?.filter((file): file is NonNullable<typeof file> => !!file?.content)
.map((file, index) => {
let filePath = resolveFilePath(file, config, {
isSrcDir: options.isSrcDir,
framework: options.framework,
commonRoot: findCommonRoot(
(files ?? []).map((entry) => entry.path),
file.path
),
path: options.path,
fileIndex: index,
})
if (!filePath) {
return null
}
if (!config.tsx) {
filePath = filePath.replace(/\.tsx?$/, (match) =>
match === ".tsx" ? ".jsx" : ".js"
)
}
return path.relative(config.resolvedPaths.cwd, filePath)
})
.filter((filePath): filePath is string => !!filePath)
}
export async function rewriteResolvedImportsInContent({
content,
resolvedPath,
filePaths,
config,
projectInfo,
tsConfig,
project,
sourceFile,
}: {
content: string
resolvedPath: string
filePaths: string[]
config: Config
projectInfo: ProjectInfo | null
tsConfig: ReturnType<typeof loadConfig>
project: Project
sourceFile?: ReturnType<Project["createSourceFile"]>
}) {
if (!projectInfo || tsConfig.resultType === "failed") {
return content
}
const ext = path.extname(resolvedPath)
if (![".tsx", ".ts", ".jsx", ".js"].includes(ext)) {
return content
}
const workingSourceFile =
sourceFile ??
project.createSourceFile(
path.join(
tmpdir(),
`shadcn-${Math.random().toString(36).slice(2)}${ext || ".tsx"}`
),
content,
{
scriptKind: ScriptKind.TSX,
overwrite: true,
}
)
let hasChanges = false
for (const importDeclaration of workingSourceFile.getImportDeclarations()) {
const moduleSpecifier = importDeclaration.getModuleSpecifierValue()
if (!isLocalAliasImport(moduleSpecifier, projectInfo.aliasPrefix ?? null)) {
continue
}
const resolvedImportFilePath = await resolveImportFilePathForRewrite(
moduleSpecifier,
filePaths,
config,
tsConfig
)
if (!resolvedImportFilePath) {
continue
}
const newImport = toAliasedImport(
resolvedImportFilePath,
config,
projectInfo,
resolvedPath
)
if (!newImport || newImport === moduleSpecifier) {
continue
}
importDeclaration.setModuleSpecifier(newImport)
hasChanges = true
}
return hasChanges ? workingSourceFile.getFullText() : content
}
async function resolveImportFilePathForRewrite(
moduleSpecifier: string,
filePaths: string[],
config: Config,
tsConfig: Pick<ConfigLoaderSuccessResult, "absoluteBaseUrl" | "paths">
) {
const probableImportFilePath = (
await resolveImportWithMetadata(moduleSpecifier, {
...tsConfig,
cwd: config.resolvedPaths.cwd,
})
)?.path
const fallbackImportFilePath =
!probableImportFilePath && !moduleSpecifier.startsWith(".")
? resolveImportFromConfiguredAliases(moduleSpecifier, config)
: null
if (!probableImportFilePath && !fallbackImportFilePath) {
return null
}
return resolveModuleByProbablePath(
probableImportFilePath ?? fallbackImportFilePath!,
filePaths,
config
)
}
/**
* Given an absolute "probable" import path (no ext),
* plus an array of absolute file paths you already know about,
@@ -694,7 +827,8 @@ export function resolveModuleByProbablePath(
export function toAliasedImport(
filePath: string,
config: Config,
projectInfo: ProjectInfo
projectInfo: ProjectInfo,
importerPath?: string
): string | null {
const abs = path.normalize(path.join(config.resolvedPaths.cwd, filePath))
@@ -716,10 +850,31 @@ export function toAliasedImport(
// force POSIX-style separators
rel = rel.split(path.sep).join("/") // e.g. "button/index.tsx"
const aliasBase =
aliasKey === "cwd"
? projectInfo.aliasPrefix
: config.aliases[aliasKey as keyof typeof config.aliases]
if (!aliasBase) {
return null
}
if (aliasBase.startsWith("#")) {
const packageImport = resolvePackageImport(
aliasBase,
config.resolvedPaths.cwd
)
if (packageImport) {
return (
toPackageImport(aliasBase, rel, packageImport) ??
(importerPath ? toRelativeImport(importerPath, abs) : null)
)
}
}
// 3⃣ Strip code-file extensions, keep others (css, json, etc.)
const ext = path.posix.extname(rel)
const codeExts = [".ts", ".tsx", ".js", ".jsx"]
const keepExt = codeExts.includes(ext) ? "" : ext
const keepExt = CODE_EXTENSIONS.includes(ext) ? "" : ext
let noExt = rel.slice(0, rel.length - ext.length)
// 4⃣ Collapse "/index" to its directory
@@ -728,26 +883,138 @@ export function toAliasedImport(
}
// 5⃣ Build the aliased path
// config.aliases[aliasKey] is e.g. "@/components/ui"
const aliasBase =
aliasKey === "cwd"
? projectInfo.aliasPrefix
: config.aliases[aliasKey as keyof typeof config.aliases]
if (!aliasBase) {
return null
}
// if noExt is empty (i.e. file was exactly at the root), we import the root
let suffix = noExt === "" ? "" : `/${noExt}`
// Remove /src from suffix.
// Alias will handle this.
suffix = suffix.replace("/src", "")
// 6⃣ Prepend the prefix from projectInfo (e.g. "@") if needed
// but usually config.aliases already include it.
return `${aliasBase}${suffix}${keepExt}`
}
function toPackageImport(
aliasBase: string,
relativePath: string,
packageImport: ReturnType<typeof resolvePackageImport> extends infer T
? Exclude<T, null>
: never
) {
const ext = path.posix.extname(relativePath)
const keepExt =
CODE_EXTENSIONS.includes(ext) &&
packageImport.emitMode === "strip_extension"
? ""
: ext
const normalizedRelativePath = relativePath
? relativePath.slice(0, relativePath.length - ext.length) + keepExt
: ""
if (!packageImport.matchedAlias.includes("*")) {
return normalizedRelativePath === "" || normalizedRelativePath === "index"
? aliasBase
: null
}
return normalizedRelativePath
? `${aliasBase}/${normalizedRelativePath}`
: aliasBase
}
function resolveImportFromConfiguredAliases(
moduleSpecifier: string,
config: Config
) {
const aliasEntries = getConfiguredAliasEntries(config)
for (const entry of aliasEntries) {
if (
moduleSpecifier === entry.alias ||
moduleSpecifier === entry.canonical
) {
return entry.rootPath
}
if (moduleSpecifier.startsWith(`${entry.alias}/`)) {
return path.join(
entry.rootPath,
moduleSpecifier.slice(entry.alias.length + 1)
)
}
if (moduleSpecifier.startsWith(`${entry.canonical}/`)) {
return path.join(
entry.rootPath,
moduleSpecifier.slice(entry.canonical.length + 1)
)
}
}
return null
}
function getConfiguredAliasEntries(config: Config) {
return [
{
alias: config.aliases.ui,
canonical: "@/components/ui",
rootPath: config.resolvedPaths.ui,
},
{
alias: config.aliases.components,
canonical: "@/components",
rootPath: config.resolvedPaths.components,
},
{
alias: config.aliases.hooks,
canonical: "@/hooks",
rootPath: config.resolvedPaths.hooks,
},
{
alias: config.aliases.lib,
canonical: "@/lib",
rootPath: config.resolvedPaths.lib,
},
{
alias: config.aliases.utils,
canonical: "@/lib/utils",
rootPath: config.resolvedPaths.utils,
},
]
.filter(
(
entry
): entry is {
alias: string
canonical: string
rootPath: string
} => typeof entry.alias === "string" && typeof entry.rootPath === "string"
)
.sort(
(a, b) =>
b.alias.length - a.alias.length ||
b.canonical.length - a.canonical.length
)
}
function toRelativeImport(fromFilePath: string, targetFilePath: string) {
let rel = path.relative(path.dirname(fromFilePath), targetFilePath)
rel = rel.split(path.sep).join("/")
const ext = path.posix.extname(rel)
const keepExt = CODE_EXTENSIONS.includes(ext) ? "" : ext
let noExt = rel.slice(0, rel.length - ext.length)
if (noExt.endsWith("/index")) {
noExt = noExt.slice(0, -"/index".length)
}
if (!noExt.startsWith(".")) {
noExt = `./${noExt}`
}
return `${noExt}${keepExt}`
}
function _isNext16Middleware(
filePath: string,
projectInfo: ProjectInfo | null,

View File

@@ -0,0 +1,251 @@
import path from "path"
import { getWorkspacePatterns } from "@/src/utils/get-monorepo-info"
import { getPackageInfo } from "@/src/utils/get-package-info"
import {
getImportTargetEmitMode,
resolveImportEntryMatch,
resolveLocalPathTarget,
type ImportEmitMode,
type ImportResolutionEntry,
type ImportResolutionMatch,
} from "@/src/utils/import-matcher"
import fg from "fast-glob"
import fs from "fs-extra"
type WorkspacePackageInfo = {
packageName: string
packageRoot: string
}
type WorkspacePackageExportEntry = ImportResolutionEntry
export type WorkspacePackageExportMatch = ImportResolutionMatch
const workspacePackageCache = new Map<
string,
Map<string, WorkspacePackageInfo>
>()
const workspaceExportEntriesCache = new Map<
string,
WorkspacePackageExportEntry[]
>()
const workspaceRootCache = new Map<string, string | null>()
export async function resolveWorkspacePackageExport(
importPath: string,
cwd: string
) {
const specifier = parsePackageSpecifier(importPath)
if (!specifier) {
return null
}
const workspacePackage = await findWorkspacePackage(
cwd,
specifier.packageName
)
if (!workspacePackage) {
return null
}
return resolveImportEntryMatch(
importPath,
getWorkspacePackageExportEntries(workspacePackage)
)
}
function getWorkspacePackageExportEntries(
workspacePackage: WorkspacePackageInfo
) {
const cacheKey = `${workspacePackage.packageRoot}:${workspacePackage.packageName}`
const cachedEntries = workspaceExportEntriesCache.get(cacheKey)
if (cachedEntries) {
return cachedEntries
}
const packageInfo = getPackageInfo(workspacePackage.packageRoot, false)
const exportsField = packageInfo?.exports
if (
!exportsField ||
typeof exportsField !== "object" ||
Array.isArray(exportsField)
) {
workspaceExportEntriesCache.set(cacheKey, [])
return []
}
const entries: WorkspacePackageExportEntry[] = []
for (const [key, value] of Object.entries(exportsField)) {
if (key !== "." && !key.startsWith("./")) {
continue
}
const target = resolveLocalPathTarget(value)
if (!target) {
continue
}
const aliasBase = getAliasBase(workspacePackage.packageName, key)
entries.push({
key: key.includes("*") ? `${aliasBase}/*` : aliasBase,
aliasBase,
target,
emitMode: getImportTargetEmitMode(target),
hasWildcard: key.includes("*"),
rootDir: workspacePackage.packageRoot,
})
}
workspaceExportEntriesCache.set(cacheKey, entries)
return entries
}
async function findWorkspacePackage(cwd: string, packageName: string) {
const workspaceRoot = await findWorkspaceRoot(cwd)
if (!workspaceRoot) {
return null
}
const cachedPackages = workspacePackageCache.get(workspaceRoot)
if (cachedPackages?.has(packageName)) {
return cachedPackages.get(packageName) ?? null
}
const workspacePackages = await loadWorkspacePackages(workspaceRoot)
workspacePackageCache.set(workspaceRoot, workspacePackages)
return workspacePackages.get(packageName) ?? null
}
async function loadWorkspacePackages(root: string) {
const patterns = await getWorkspacePatterns(root)
const packageMap = new Map<string, WorkspacePackageInfo>()
if (!patterns.length) {
return packageMap
}
const packageJsonPaths = await fg(
patterns.map((pattern) =>
path.posix.join(pattern.split(path.sep).join("/"), "package.json")
),
{
cwd: root,
ignore: ["**/node_modules/**"],
}
)
for (const packageJsonPath of packageJsonPaths) {
const packageRoot = path.resolve(root, path.dirname(packageJsonPath))
const packageInfo = getPackageInfo(packageRoot, false)
const name = packageInfo?.name
if (!name) {
continue
}
packageMap.set(name, {
packageName: name,
packageRoot,
})
}
return packageMap
}
async function findWorkspaceRoot(cwd: string) {
const start = path.resolve(cwd)
const cachedRoot = workspaceRootCache.get(start)
if (cachedRoot !== undefined) {
return cachedRoot
}
let current = start
const gitRoot = await findGitRoot(start)
while (true) {
const patterns = await getWorkspacePatterns(current)
if (patterns.length) {
workspaceRootCache.set(start, current)
return current
}
if (gitRoot && current === gitRoot) {
workspaceRootCache.set(start, null)
return null
}
const parent = path.dirname(current)
if (parent === current) {
workspaceRootCache.set(start, null)
return null
}
current = parent
}
}
async function findGitRoot(cwd: string) {
let current = path.resolve(cwd)
while (true) {
if (fs.existsSync(path.resolve(current, ".git"))) {
return current
}
const parent = path.dirname(current)
if (parent === current) {
return null
}
current = parent
}
}
function parsePackageSpecifier(importPath: string) {
if (
importPath.startsWith("#") ||
importPath.startsWith(".") ||
path.isAbsolute(importPath)
) {
return null
}
const segments = importPath.split("/")
if (importPath.startsWith("@")) {
if (segments.length < 2) {
return null
}
return {
packageName: `${segments[0]}/${segments[1]}`,
}
}
return {
packageName: segments[0],
}
}
function getAliasBase(packageName: string, exportKey: string) {
if (exportKey === ".") {
return packageName
}
const normalizedKey = exportKey.slice(2).replace(/\/\*$/, "")
return normalizedKey ? `${packageName}/${normalizedKey}` : packageName
}

View File

@@ -0,0 +1,16 @@
{
"style": "new-york",
"tailwind": {
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true
},
"rsc": false,
"tsx": true,
"aliases": {
"components": "#components",
"ui": "#components/ui",
"lib": "#lib",
"utils": "#lib/utils"
}
}

View File

@@ -0,0 +1,8 @@
{
"name": "config-imports-extensions",
"type": "module",
"imports": {
"#components/*": "./src/components/*.tsx",
"#lib/*": "./src/lib/*.ts"
}
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,18 @@
{
"style": "new-york",
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"rsc": true,
"tsx": true,
"aliases": {
"components": "#components",
"ui": "#components/ui",
"lib": "#lib",
"hooks": "#hooks",
"utils": "#utils"
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "config-imports",
"type": "module",
"imports": {
"#components/*": "./src/components/*",
"#components/ui/*": "./src/components/ui/*",
"#lib/*": "./src/lib/*",
"#hooks": "./src/hooks/index.ts",
"#utils": "./src/lib/utils.ts"
}
}

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
export default nextConfig

View File

@@ -0,0 +1,21 @@
{
"name": "next-app-imports",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"tailwindcss": "^3.0.0"
},
"imports": {
"#components/*": "./src/components/*",
"#components/ui/*": "./src/components/ui/*",
"#lib/*": "./src/lib/*",
"#hooks": "./src/hooks/index.ts",
"#utils": "./src/lib/utils.ts"
}
}

View File

@@ -0,0 +1,11 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div>Hello</div>
}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1 @@
export default {}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"resolvePackageJsonImports": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"name": "vite-app-imports",
"private": true,
"version": "0.0.0",
"type": "module",
"dependencies": {
"tailwindcss": "^4.1.11"
},
"imports": {
"#custom/*": "./src/*"
}
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,3 @@
import { defineConfig } from "vite"
export default defineConfig({})

View File

@@ -0,0 +1,18 @@
{
"style": "new-york",
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"rsc": false,
"tsx": true,
"aliases": {
"components": "#components",
"ui": "@workspace/ui/components",
"lib": "#lib",
"hooks": "#hooks",
"utils": "@workspace/ui/lib/utils"
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "web",
"private": true,
"type": "module",
"imports": {
"#*": "./src/*"
},
"dependencies": {
"@workspace/ui": "workspace:*",
"tailwindcss": "^4.2.1"
}
}

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "vite-monorepo-imports",
"private": true,
"workspaces": ["apps/*", "packages/*"]
}

View File

@@ -0,0 +1,18 @@
{
"style": "new-york",
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"rsc": false,
"tsx": true,
"aliases": {
"components": "#components",
"ui": "#components",
"lib": "#lib",
"hooks": "#hooks",
"utils": "#lib/utils"
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "@workspace/ui",
"private": true,
"type": "module",
"imports": {
"#*": "./src/*"
},
"exports": {
"./globals.css": "./src/styles/globals.css",
"./components/*": "./src/components/*.tsx",
"./lib/*": "./src/lib/*.ts",
"./hooks/*": "./src/hooks/*.ts"
}
}

View File

@@ -0,0 +1,3 @@
export function cn(...inputs: Array<string | undefined | false | null>) {
return inputs.filter(Boolean).join(" ")
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,13 @@
{
"name": "vite-partial-imports",
"private": true,
"version": "0.0.0",
"type": "module",
"imports": {
"#components/*": "./src/components/*",
"#lib/*": "./src/lib/*"
},
"dependencies": {
"tailwindcss": "^4.2.1"
}
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"#components/*": ["./src/components/*"],
"#lib/*": ["./src/lib/*"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }]
}

View File

@@ -0,0 +1,3 @@
import { defineConfig } from "vite"
export default defineConfig({})

View File

@@ -0,0 +1,12 @@
{
"name": "vite-root-imports",
"private": true,
"version": "0.0.0",
"type": "module",
"dependencies": {
"tailwindcss": "^4.1.11"
},
"imports": {
"#*": "./src/*"
}
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,3 @@
import { defineConfig } from "vite"
export default defineConfig({})

View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["src"]
}

View File

@@ -0,0 +1,10 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "with-package-imports",
"type": "module",
"imports": {
"#components/*": "./src/components/*",
"#components-ext/*": "./src/components/*.tsx",
"#hooks": "./src/hooks/index.ts",
"#utils": "./src/lib/utils.ts",
"#dep": {
"node": "dep-node-native",
"default": "./dep-polyfill.js"
}
}
}

View File

@@ -46,7 +46,7 @@ async function loadMultiple() {
exports[`transform dynamic imports with cn utility 2`] = `
"async function loadWorkspaceCn() {
const { cn } = await import("@workspace/lib/utils")
const { cn } = await import("@workspace/ui/lib/utils")
return cn
}
"

View File

@@ -1,6 +1,9 @@
import { existsSync, promises as fs } from "fs"
import path from "path"
import { afterEach, describe, expect, test, vi } from "vitest"
import type { Config } from "../../src/utils/get-config"
import { getConfig } from "../../src/utils/get-config"
// Mock external dependencies.
vi.mock("../../src/registry/resolver", () => ({
@@ -94,9 +97,22 @@ import {
} from "../../src/utils/dry-run-formatter"
import type { DryRunResult } from "../../src/utils/dry-run"
import { resolveRegistryTree } from "../../src/registry/resolver"
import { getProjectInfo } from "../../src/utils/get-project-info"
import { transform } from "../../src/utils/transformers"
import { transformAsChild } from "../../src/utils/transformers/transform-aschild"
import { transformCleanup } from "../../src/utils/transformers/transform-cleanup"
import { transformCssVars as transformCssVarsTransformer } from "../../src/utils/transformers/transform-css-vars"
import { transformIcons } from "../../src/utils/transformers/transform-icons"
import { transformImport } from "../../src/utils/transformers/transform-import"
import { transformMenu } from "../../src/utils/transformers/transform-menu"
import { transformRsc } from "../../src/utils/transformers/transform-rsc"
import { transformRtl } from "../../src/utils/transformers/transform-rtl"
import { transformTwPrefixes } from "../../src/utils/transformers/transform-tw-prefix"
afterEach(() => {
vi.clearAllMocks()
vi.mocked(existsSync).mockReturnValue(false)
vi.mocked(fs.readFile).mockResolvedValue("" as never)
})
function createMockConfig(overrides: Partial<Config> = {}): Config {
@@ -408,6 +424,244 @@ describe("dryRunComponents", () => {
dryRunComponents(["nonexistent"], config)
).rejects.toThrow("Failed to fetch components from registry.")
})
test("should skip package-import files when final rewritten content matches", async () => {
const tempDir = path.join(
path.resolve(__dirname, "../fixtures"),
"temp-dry-run-package-import-same"
)
const actualFs = (await vi.importActual("fs")) as typeof import("fs")
try {
vi.mocked(existsSync).mockImplementation(actualFs.existsSync)
vi.mocked(fs.readFile).mockImplementation(actualFs.promises.readFile as never)
vi.mocked(getProjectInfo).mockResolvedValue({
framework: { name: "vite" } as any,
isSrcDir: true,
isRSC: false,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "src/index.css",
tailwindVersion: "v4",
frameworkVersion: null,
aliasPrefix: "#",
})
await actualFs.promises.rm(tempDir, { recursive: true, force: true })
await actualFs.promises.mkdir(path.join(tempDir, "src", "components", "ui"), {
recursive: true,
})
await actualFs.promises.mkdir(path.join(tempDir, "src", "lib"), {
recursive: true,
})
await actualFs.promises.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify(
{
name: "temp-dry-run-package-import-same",
type: "module",
imports: {
"#components/*": "./src/components/*",
"#lib/*": "./src/lib/*",
},
},
null,
2
),
"utf-8"
)
await actualFs.promises.writeFile(
path.join(tempDir, "tsconfig.json"),
JSON.stringify(
{
files: [],
references: [{ path: "./tsconfig.app.json" }],
},
null,
2
),
"utf-8"
)
await actualFs.promises.writeFile(
path.join(tempDir, "tsconfig.app.json"),
JSON.stringify(
{
compilerOptions: {
module: "esnext",
moduleResolution: "bundler",
baseUrl: ".",
paths: {
"#components/*": ["./src/components/*"],
"#lib/*": ["./src/lib/*"],
},
},
},
null,
2
),
"utf-8"
)
await actualFs.promises.writeFile(
path.join(tempDir, "src", "index.css"),
'@import "tailwindcss";\n',
"utf-8"
)
await actualFs.promises.writeFile(
path.join(tempDir, "src", "lib", "utils.ts"),
"export function cn(...inputs: unknown[]) {\n return inputs\n}\n",
"utf-8"
)
await actualFs.promises.writeFile(
path.join(tempDir, "src", "components", "ui", "button.tsx"),
`import { cn } from "#lib/utils.ts"
export function Button() {
return <button>{cn("button")}</button>
}
`,
"utf-8"
)
const config = createMockConfig({
rsc: false,
aliases: {
components: "#components",
utils: "#lib/utils",
ui: "#components/ui",
lib: "#lib",
hooks: undefined,
},
resolvedPaths: {
cwd: tempDir,
tailwindConfig: "",
tailwindCss: path.join(tempDir, "src", "index.css"),
utils: path.join(tempDir, "src", "lib", "utils.ts"),
components: path.join(tempDir, "src", "components"),
lib: path.join(tempDir, "src", "lib"),
hooks: path.join(tempDir, "src", "hooks"),
ui: path.join(tempDir, "src", "components", "ui"),
},
})
vi.mocked(resolveRegistryTree).mockResolvedValue({
name: "button",
files: [
{
path: "registry/default/ui/button.tsx",
type: "registry:ui",
content: `import { cn } from "#lib/utils"
export function Button() {
return <button>{cn("button")}</button>
}
`,
},
],
dependencies: [],
devDependencies: [],
})
const result = await dryRunComponents(["button"], config)
expect(result.files).toHaveLength(1)
expect(result.files[0]).toMatchObject({
path: "src/components/ui/button.tsx",
action: "skip",
})
expect(result.files[0].content).toContain(`from "#lib/utils.ts"`)
} finally {
await actualFs.promises.rm(tempDir, { recursive: true, force: true })
}
})
test("should rewrite app-local files to workspace utils aliases in monorepo dry-runs", async () => {
const actualFs = (await vi.importActual("fs")) as typeof import("fs")
const actualTransformModule = (await vi.importActual(
"../../src/utils/transformers"
)) as typeof import("../../src/utils/transformers")
const actualTransformImportModule = (await vi.importActual(
"../../src/utils/transformers/transform-import"
)) as typeof import("../../src/utils/transformers/transform-import")
const cwd = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web"
)
vi.mocked(existsSync).mockImplementation(actualFs.existsSync)
vi.mocked(fs.readFile).mockImplementation(actualFs.promises.readFile as never)
vi.mocked(getProjectInfo).mockResolvedValue({
framework: { name: "vite" } as any,
isSrcDir: true,
isRSC: false,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "../../packages/ui/src/styles/globals.css",
tailwindVersion: "v4",
frameworkVersion: null,
aliasPrefix: "#",
})
vi.mocked(transform).mockImplementationOnce(actualTransformModule.transform)
vi.mocked(transformImport).mockImplementationOnce(
actualTransformImportModule.transformImport
)
for (const transformer of [
transformRsc,
transformCssVarsTransformer,
transformTwPrefixes,
transformIcons,
transformMenu,
transformAsChild,
transformRtl,
transformCleanup,
]) {
vi.mocked(transformer).mockImplementationOnce(async ({ sourceFile }) => {
return sourceFile
})
}
const config = await getConfig(cwd)
if (!config) {
throw new Error("Failed to get monorepo app config")
}
vi.mocked(resolveRegistryTree).mockResolvedValue({
name: "login-03",
files: [
{
path: "registry/components/login-form.tsx",
type: "registry:component",
content: `import { cn } from "@/lib/utils"
export function LoginForm() {
return <div>{cn("login")}</div>
}
`,
},
],
dependencies: [],
devDependencies: [],
})
const result = await dryRunComponents(["login-03"], config)
expect(result.files).toHaveLength(1)
expect(result.files[0]).toMatchObject({
path: "src/components/login-form.tsx",
action: "create",
type: "registry:component",
})
expect(result.files[0].content).toContain(
`from "@workspace/ui/lib/utils"`
)
expect(result.files[0].content).not.toContain(`from "#lib/utils"`)
})
})
describe("formatDryRunResult", () => {

View File

@@ -1,4 +1,6 @@
import os from "os"
import path from "path"
import fs from "fs-extra"
import { describe, expect, test } from "vitest"
import {
@@ -6,7 +8,9 @@ import {
getBase,
getConfig,
getRawConfig,
getWorkspaceConfig,
} from "../../src/utils/get-config"
import { getProjectConfig } from "../../src/utils/get-project-info"
test("get raw config", async () => {
expect(
@@ -36,6 +40,164 @@ test("get raw config", async () => {
).rejects.toThrowError()
})
test("get project config from package imports", async () => {
const cwd = path.resolve(__dirname, "../fixtures/frameworks/next-app-imports")
expect(await getProjectConfig(cwd)).toEqual({
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: true,
tsx: true,
tailwind: {
config: "tailwind.config.ts",
baseColor: "zinc",
css: "src/app/styles.css",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#utils",
},
resolvedPaths: {
cwd,
tailwindConfig: path.resolve(cwd, "tailwind.config.ts"),
tailwindCss: path.resolve(cwd, "src/app/styles.css"),
components: path.resolve(cwd, "src/components"),
ui: path.resolve(cwd, "src/components/ui"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
utils: path.resolve(cwd, "src/lib/utils.ts"),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
})
test("get project config from generic package import prefix", async () => {
const cwd = path.resolve(__dirname, "../fixtures/frameworks/vite-app-imports")
expect(await getProjectConfig(cwd)).toEqual({
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
baseColor: "zinc",
css: "src/index.css",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: "#custom/components",
ui: "#custom/components/ui",
lib: "#custom/lib",
hooks: "#custom/hooks",
utils: "#custom/lib/utils",
},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: path.resolve(cwd, "src/index.css"),
components: path.resolve(cwd, "src/components"),
ui: path.resolve(cwd, "src/components/ui"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
utils: path.resolve(cwd, "src/lib/utils"),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
})
test("get project config from root package imports", async () => {
const cwd = path.resolve(__dirname, "../fixtures/frameworks/vite-root-imports")
expect(await getProjectConfig(cwd)).toEqual({
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
baseColor: "zinc",
css: "src/index.css",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#lib/utils",
},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: path.resolve(cwd, "src/index.css"),
components: path.resolve(cwd, "src/components"),
ui: path.resolve(cwd, "src/components/ui"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
utils: path.resolve(cwd, "src/lib/utils"),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
})
test("get project config from partial package imports", async () => {
const cwd = path.resolve(
__dirname,
"../fixtures/frameworks/vite-partial-imports"
)
expect(await getProjectConfig(cwd)).toEqual({
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
baseColor: "zinc",
css: "src/index.css",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
utils: "#lib/utils",
},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: path.resolve(cwd, "src/index.css"),
components: path.resolve(cwd, "src/components"),
ui: path.resolve(cwd, "src/components/ui"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
utils: path.resolve(cwd, "src/lib/utils"),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
})
test("get config", async () => {
expect(
await getConfig(path.resolve(__dirname, "../fixtures/config-none"))
@@ -196,6 +358,282 @@ test("get config", async () => {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
expect(
await getConfig(path.resolve(__dirname, "../fixtures/config-imports"))
).toEqual({
style: "new-york",
rsc: true,
tsx: true,
tailwind: {
config: "tailwind.config.ts",
baseColor: "zinc",
css: "src/app/globals.css",
cssVariables: true,
},
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
utils: "#utils",
},
iconLibrary: "radix",
resolvedPaths: {
cwd: path.resolve(__dirname, "../fixtures/config-imports"),
tailwindConfig: path.resolve(
__dirname,
"../fixtures/config-imports",
"tailwind.config.ts"
),
tailwindCss: path.resolve(
__dirname,
"../fixtures/config-imports",
"src/app/globals.css"
),
components: path.resolve(
__dirname,
"../fixtures/config-imports",
"src/components"
),
ui: path.resolve(
__dirname,
"../fixtures/config-imports",
"src/components/ui"
),
lib: path.resolve(__dirname, "../fixtures/config-imports", "src/lib"),
hooks: path.resolve(__dirname, "../fixtures/config-imports", "src/hooks"),
utils: path.resolve(
__dirname,
"../fixtures/config-imports",
"src/lib/utils.ts"
),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
expect(
await getConfig(
path.resolve(__dirname, "../fixtures/config-imports-extensions")
)
).toEqual({
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
css: "src/index.css",
baseColor: "zinc",
cssVariables: true,
},
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
utils: "#lib/utils",
},
iconLibrary: "radix",
resolvedPaths: {
cwd: path.resolve(__dirname, "../fixtures/config-imports-extensions"),
tailwindConfig: "",
tailwindCss: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/index.css"
),
components: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/components"
),
ui: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/components/ui"
),
lib: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/lib"
),
hooks: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/hooks"
),
utils: path.resolve(
__dirname,
"../fixtures/config-imports-extensions",
"src/lib/utils.ts"
),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
expect(
await getConfig(
path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web"
)
)
).toEqual({
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
css: "../../packages/ui/src/styles/globals.css",
baseColor: "zinc",
cssVariables: true,
},
aliases: {
components: "#components",
ui: "@workspace/ui/components",
lib: "#lib",
hooks: "#hooks",
utils: "@workspace/ui/lib/utils",
},
iconLibrary: "radix",
resolvedPaths: {
cwd: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web"
),
tailwindConfig: "",
tailwindCss: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/packages/ui/src/styles/globals.css"
),
components: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web/src/components"
),
ui: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/packages/ui/src/components"
),
lib: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web/src/lib"
),
hooks: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web/src/hooks"
),
utils: path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/packages/ui/src/lib/utils.ts"
),
},
registries: {
"@shadcn": "https://ui.shadcn.com/r/styles/{style}/{name}.json",
},
})
})
test("get workspace config resolves cross-package aliases without tsconfig paths", async () => {
const appCwd = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/apps/web"
)
const uiCwd = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports/packages/ui"
)
const config = await getConfig(appCwd)
if (!config) {
throw new Error("Failed to load monorepo app config")
}
expect(await getWorkspaceConfig(config)).toMatchObject({
components: {
resolvedPaths: {
cwd: appCwd,
},
},
ui: {
resolvedPaths: {
cwd: uiCwd,
},
},
lib: {
resolvedPaths: {
cwd: appCwd,
},
},
hooks: {
resolvedPaths: {
cwd: appCwd,
},
},
})
})
test("get workspace config shows an actionable error when a workspace package is missing imports", async () => {
const fixtureRoot = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports"
)
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), "shadcn-workspace-config-")
)
try {
await fs.copy(fixtureRoot, tempDir)
const uiPackageJsonPath = path.resolve(tempDir, "packages/ui/package.json")
const uiPackageJson = await fs.readJson(uiPackageJsonPath)
delete uiPackageJson.imports
await fs.writeJson(uiPackageJsonPath, uiPackageJson, { spaces: 2 })
const config = await getConfig(path.resolve(tempDir, "apps/web"))
if (!config) {
throw new Error("Failed to load broken monorepo app config")
}
await expect(getWorkspaceConfig(config)).rejects.toThrowError(
new RegExp(
"Could not resolve the following aliases.*packages/ui.*components, ui, lib, hooks, utils",
"s"
)
)
} finally {
await fs.remove(tempDir)
}
})
test("get workspace config shows an actionable error when a workspace package is missing components.json", async () => {
const fixtureRoot = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports"
)
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), "shadcn-workspace-config-")
)
try {
await fs.copy(fixtureRoot, tempDir)
await fs.remove(path.resolve(tempDir, "packages/ui/components.json"))
const config = await getConfig(path.resolve(tempDir, "apps/web"))
if (!config) {
throw new Error("Failed to load broken monorepo app config")
}
await expect(getWorkspaceConfig(config)).rejects.toThrowError(
new RegExp(
"Could not load the workspace config.*packages/ui.*components.json.*path aliases or package imports",
"s"
)
)
} finally {
await fs.remove(tempDir)
}
})
describe("getBase", () => {

View File

@@ -48,6 +48,62 @@ describe("get project info", async () => {
aliasPrefix: "#",
},
},
{
name: "next-app-imports",
type: {
framework: FRAMEWORKS["next-app"],
isSrcDir: true,
isRSC: true,
isTsx: true,
tailwindConfigFile: "tailwind.config.ts",
tailwindCssFile: "src/app/styles.css",
tailwindVersion: "v3",
frameworkVersion: null,
aliasPrefix: "#",
},
},
{
name: "vite-app-imports",
type: {
framework: FRAMEWORKS["vite"],
isSrcDir: true,
isRSC: false,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "src/index.css",
tailwindVersion: "v4",
frameworkVersion: null,
aliasPrefix: "#custom",
},
},
{
name: "vite-root-imports",
type: {
framework: FRAMEWORKS["vite"],
isSrcDir: true,
isRSC: false,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "src/index.css",
tailwindVersion: "v4",
frameworkVersion: null,
aliasPrefix: "#",
},
},
{
name: "vite-partial-imports",
type: {
framework: FRAMEWORKS["vite"],
isSrcDir: true,
isRSC: false,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "src/index.css",
tailwindVersion: "v4",
frameworkVersion: null,
aliasPrefix: "#",
},
},
{
name: "next-pages",
type: {

View File

@@ -29,6 +29,14 @@ describe("get ts config alias prefix", async () => {
name: "next-app-custom-alias",
prefix: "@custom-alias",
},
{
name: "vite-partial-imports",
prefix: "#components",
},
{
name: "vite-root-paths",
prefix: "@",
},
])(`getTsConfigAliasPrefix($name) -> $prefix`, async ({ name, prefix }) => {
expect(
await getTsConfigAliasPrefix(

View File

@@ -1,8 +1,12 @@
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"
import {
isLocalAliasImport,
resolveImport,
resolveImportWithMetadata,
} from "../../src/utils/resolve-import"
test("resolve import", async () => {
expect(
@@ -79,3 +83,114 @@ test("resolve import without base url", async () => {
path.resolve(cwd, "foo/bar")
)
})
describe("resolve package imports", () => {
const cwd = path.resolve(__dirname, "../fixtures/with-package-imports")
const config = {
absoluteBaseUrl: cwd,
paths: {},
cwd,
}
test("resolves wildcard imports that preserve extensions", async () => {
const result = await resolveImportWithMetadata("#components/button.tsx", config)
expect(result).toEqual({
path: path.resolve(cwd, "src/components/button.tsx"),
source: "package_imports",
matchedAlias: "#components/*",
matchedTarget: "./src/components/*",
emitMode: "preserve_extension",
})
})
test("resolves wildcard imports that strip extensions", async () => {
const result = await resolveImportWithMetadata("#components-ext/button", config)
expect(result).toEqual({
path: path.resolve(cwd, "src/components/button.tsx"),
source: "package_imports",
matchedAlias: "#components-ext/*",
matchedTarget: "./src/components/*.tsx",
emitMode: "strip_extension",
})
})
test("resolves the root alias for wildcard package imports", async () => {
expect(await resolveImport("#components", config)).toEqual(
path.resolve(cwd, "src/components")
)
})
test("resolves exact imports and prefers local conditional targets", async () => {
expect(await resolveImport("#hooks", config)).toEqual(
path.resolve(cwd, "src/hooks/index.ts")
)
expect(await resolveImport("#dep", config)).toEqual(
path.resolve(cwd, "dep-polyfill.js")
)
})
test("falls back to tsconfig paths when package imports do not match", async () => {
expect(
await resolveImportWithMetadata("#/components/ui", {
absoluteBaseUrl: "/Users/shadcn/Projects/foobar",
cwd,
paths: {
"#/*": ["./src/*"],
},
})
).toEqual({
path: "/Users/shadcn/Projects/foobar/src/components/ui",
source: "tsconfig_paths",
matchedAlias: "#/*",
matchedTarget: "./src/components/ui",
emitMode: "strip_extension",
})
})
})
describe("resolve workspace package exports", () => {
const root = path.resolve(
__dirname,
"../fixtures/frameworks/vite-monorepo-imports"
)
const cwd = path.resolve(root, "apps/web")
const config = {
absoluteBaseUrl: cwd,
paths: {},
cwd,
}
test("resolves workspace package wildcard exports for file imports", async () => {
const result = await resolveImportWithMetadata(
"@workspace/ui/components/button",
config
)
expect(result).toEqual({
path: path.resolve(root, "packages/ui/src/components/button.tsx"),
source: "workspace_package_exports",
matchedAlias: "@workspace/ui/components/*",
matchedTarget: "./src/components/*.tsx",
emitMode: "strip_extension",
})
})
test("resolves bare alias roots from workspace package wildcard exports", async () => {
expect(await resolveImport("@workspace/ui/components", config)).toEqual(
path.resolve(root, "packages/ui/src/components")
)
expect(await resolveImport("@workspace/ui/lib/utils", config)).toEqual(
path.resolve(root, "packages/ui/src/lib/utils.ts")
)
})
test("does not treat workspace package exports as local alias imports", () => {
expect(isLocalAliasImport("@workspace/ui/components/button", "#")).toBe(
false
)
})
})

View File

@@ -34,6 +34,49 @@ test('transform nested workspace folder for utils, website/src/utils', async ()
})
test.each([
{
name: "bare aliases",
aliases: {
components: "components",
ui: "components/ui",
lib: "lib",
utils: "lib/utils",
},
buttonImport: `import { Button } from "components/ui/button"`,
utilsImport: `import { cn } from "lib/utils"`,
},
{
name: "path-like aliases",
aliases: {
components: "website/src/components",
ui: "website/src/components/ui",
lib: "website/src/lib",
utils: "website/src/lib/utils",
},
buttonImport: `import { Button } from "website/src/components/ui/button"`,
utilsImport: `import { cn } from "website/src/lib/utils"`,
},
])("transform import with non-sigil aliases: $name", async ({
aliases,
buttonImport,
utilsImport,
}) => {
const result = await transform({
filename: "test.ts",
raw: `import { Button } from "@/registry/new-york/ui/button"
import { cn } from "@/lib/utils"
`,
config: {
tsx: true,
aliases,
},
})
expect(result).toContain(buttonImport)
expect(result).toContain(utilsImport)
})
test("transform import", async () => {
expect(
await transform({
@@ -176,6 +219,53 @@ import { Foo } from "bar"
).toMatchSnapshot()
})
test("transform import with configured package-import aliases", async () => {
expect(
await transform({
filename: "test.ts",
raw: `import { Button } from "#app/components/ui/button"
import { cn } from "#app/lib/utils"
`,
config: {
tsx: true,
aliases: {
components: "#app/components",
ui: "#app/components/ui",
lib: "#app/lib",
utils: "#app/lib/utils",
},
},
})
).toMatchInlineSnapshot(`
"import { Button } from "#app/components/ui/button"
import { cn } from "#app/lib/utils"
"
`)
})
test("transform import keeps exact #utils aliases", async () => {
expect(
await transform({
filename: "test.ts",
raw: `import { cn } from "@/lib/utils"
`,
config: {
tsx: true,
aliases: {
components: "#components",
utils: "#utils",
ui: "#components/ui",
lib: "#lib",
hooks: "#hooks",
},
},
})
).toMatchInlineSnapshot(`
"import { cn } from "#utils"
"
`)
})
test("transform import for monorepo", async () => {
expect(
await transform({
@@ -228,6 +318,160 @@ import { Foo } from "bar"
).toMatchSnapshot()
})
test("transform package import aliases and #registry placeholders", async () => {
expect(
await transform({
filename: "test.ts",
raw: `import { Button } from "#registry/new-york/ui/button"
import { Card } from "#/registry/new-york/ui/card"
import * as RegistryRoot from "#registry"
import * as RegistryRootCompat from "#/registry"
import { cn } from "#utils"
import { helper } from "#lib/helpers"
import { useThing } from "#hooks/use-thing"
`,
config: {
tsx: true,
aliases: {
components: "#components",
ui: "#components/ui",
utils: "#utils",
lib: "#lib",
hooks: "#hooks",
},
},
})
).toContain(`import { Button } from "#components/ui/button"`)
expect(
await transform({
filename: "test.ts",
raw: `import { Button } from "#registry/new-york/ui/button"
import { Card } from "#/registry/new-york/ui/card"
import * as RegistryRoot from "#registry"
import * as RegistryRootCompat from "#/registry"
import { cn } from "#utils"
import { helper } from "#lib/helpers"
import { useThing } from "#hooks/use-thing"
`,
config: {
tsx: true,
aliases: {
components: "#components",
ui: "#components/ui",
utils: "#utils",
lib: "#lib",
hooks: "#hooks",
},
},
})
).toContain(`import { Card } from "#components/ui/card"`)
expect(
await transform({
filename: "test.ts",
raw: `import { Button } from "#registry/new-york/ui/button"
import { Card } from "#/registry/new-york/ui/card"
import * as RegistryRoot from "#registry"
import * as RegistryRootCompat from "#/registry"
import { cn } from "#utils"
import { helper } from "#lib/helpers"
import { useThing } from "#hooks/use-thing"
`,
config: {
tsx: true,
aliases: {
components: "#components",
ui: "#components/ui",
utils: "#utils",
lib: "#lib",
hooks: "#hooks",
},
},
})
).toContain(`import { cn } from "#utils"`)
expect(
await transform({
filename: "test.ts",
raw: `import * as RegistryRoot from "#registry"
import * as RegistryRootCompat from "#/registry"
`,
config: {
tsx: true,
aliases: {
components: "#components",
ui: "#components/ui",
utils: "#utils",
lib: "#lib",
hooks: "#hooks",
},
},
})
).toContain(`import * as RegistryRoot from "#components"`)
expect(
await transform({
filename: "test.ts",
raw: `import * as RegistryRoot from "#registry"
import * as RegistryRootCompat from "#/registry"
`,
config: {
tsx: true,
aliases: {
components: "#components",
ui: "#components/ui",
utils: "#utils",
lib: "#lib",
hooks: "#hooks",
},
},
})
).toContain(`import * as RegistryRootCompat from "#components"`)
})
test("prefers explicit workspace utils alias over local lib alias", async () => {
expect(
await transform({
filename: "test.tsx",
raw: `import { cn } from "@/lib/utils"
import { helper } from "@/lib/helper"
`,
config: {
tsx: true,
aliases: {
components: "#components",
lib: "#lib",
hooks: "#hooks",
ui: "@workspace/ui/components",
utils: "@workspace/ui/lib/utils",
},
},
})
).toContain(`import { cn } from "@workspace/ui/lib/utils"`)
})
test("prefers explicit utils alias for registry lib utils imports", async () => {
expect(
await transform({
filename: "login-form.tsx",
raw: `import { cn } from "@/registry/new-york-v4/lib/utils"
import { Button } from "@/registry/new-york-v4/ui/button"
`,
config: {
tsx: true,
aliases: {
components: "#components",
lib: "#lib",
hooks: "#hooks",
ui: "@workspace/ui/components",
utils: "@workspace/ui/lib/utils",
},
},
})
).toContain(`import { cn } from "@workspace/ui/lib/utils"`)
})
test("transform async/dynamic imports", async () => {
expect(
await transform({

View File

@@ -1,6 +1,7 @@
import { existsSync, promises as fs } from "fs"
import path from "path"
import { afterAll, afterEach, describe, expect, test, vi } from "vitest"
import prompts from "prompts"
import { getConfig } from "../../../src/utils/get-config"
import {
@@ -1073,6 +1074,298 @@ return <div>Hello World</div>
`)
})
test("should rewrite exact package-import subpaths to valid relative imports", async () => {
const tempDir = path.join(
path.resolve(__dirname, "../../fixtures"),
"temp-package-import-exact-hook"
)
const fsActual = (await vi.importActual(
"fs/promises"
)) as typeof import("fs/promises")
const fsModuleActual = (await vi.importActual("fs")) as typeof import("fs")
const writeFileMock = fs.writeFile as any
try {
writeFileMock.mockImplementation(fsModuleActual.promises.writeFile as any)
await fsActual.rm(tempDir, { recursive: true, force: true })
await fsActual.mkdir(path.join(tempDir, "src", "app"), { recursive: true })
await fsActual.mkdir(path.join(tempDir, "src", "hooks"), {
recursive: true,
})
await fsActual.mkdir(path.join(tempDir, "src", "lib"), { recursive: true })
await fsActual.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify(
{
name: "temp-package-import-exact-hook",
type: "module",
imports: {
"#components/*": "./src/components/*",
"#hooks": "./src/hooks/index.ts",
"#utils": "./src/lib/utils.ts",
},
},
null,
2
),
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "tsconfig.json"),
JSON.stringify(
{
compilerOptions: {
module: "esnext",
moduleResolution: "bundler",
resolvePackageJsonImports: true,
},
},
null,
2
),
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "components.json"),
JSON.stringify(
{
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: true,
tsx: true,
tailwind: {
config: "",
css: "src/app/globals.css",
baseColor: "zinc",
cssVariables: true,
},
aliases: {
components: "#components",
hooks: "#hooks",
utils: "#utils",
},
},
null,
2
),
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "src", "app", "globals.css"),
'@import "tailwindcss";\n',
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "src", "hooks", "index.ts"),
'export * from "./use-thing"\n',
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "src", "lib", "utils.ts"),
"export function cn() {}\n",
"utf-8"
)
const config = await getConfig(tempDir)
if (!config) {
throw new Error("Failed to get config")
}
await updateFiles(
[
{
path: "components/example-card.tsx",
type: "registry:component",
content: `import { useThing } from "@/hooks/use-thing"
export function ExampleCard() {
useThing()
return null
}
`,
},
{
path: "hooks/use-thing.ts",
type: "registry:hook",
content: `export function useThing() {
return true
}
`,
},
],
config,
{
overwrite: true,
silent: true,
}
)
const componentContents = await fsActual.readFile(
path.join(tempDir, "src", "components", "example-card.tsx"),
"utf-8"
)
expect(componentContents).toContain(`from "../hooks/use-thing"`)
expect(componentContents).not.toContain(`from "#hooks/use-thing"`)
} finally {
writeFileMock.mockResolvedValue(undefined)
await fsActual.rm(tempDir, { recursive: true, force: true }).catch(() => {})
}
})
test("should skip existing package-import files when final content is identical", async () => {
const tempDir = path.join(
path.resolve(__dirname, "../../fixtures"),
"temp-package-import-same-content"
)
const fsActual = (await vi.importActual(
"fs/promises"
)) as typeof import("fs/promises")
const fsModuleActual = (await vi.importActual("fs")) as typeof import("fs")
const writeFileMock = fs.writeFile as any
try {
writeFileMock.mockImplementation(fsModuleActual.promises.writeFile as any)
await fsActual.rm(tempDir, { recursive: true, force: true })
await fsActual.mkdir(path.join(tempDir, "src", "components", "ui"), {
recursive: true,
})
await fsActual.mkdir(path.join(tempDir, "src", "lib"), {
recursive: true,
})
await fsActual.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify(
{
name: "temp-package-import-same-content",
type: "module",
imports: {
"#components/*": "./src/components/*",
"#lib/*": "./src/lib/*",
},
},
null,
2
),
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "tsconfig.json"),
JSON.stringify(
{
files: [],
references: [{ path: "./tsconfig.app.json" }],
},
null,
2
),
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "tsconfig.app.json"),
JSON.stringify(
{
compilerOptions: {
module: "esnext",
moduleResolution: "bundler",
baseUrl: ".",
paths: {
"#components/*": ["./src/components/*"],
"#lib/*": ["./src/lib/*"],
},
},
},
null,
2
),
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "components.json"),
JSON.stringify(
{
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
css: "src/index.css",
baseColor: "zinc",
cssVariables: true,
},
aliases: {
components: "#components",
ui: "#components/ui",
lib: "#lib",
utils: "#lib/utils",
},
},
null,
2
),
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "src", "index.css"),
'@import "tailwindcss";\n',
"utf-8"
)
await fsActual.writeFile(
path.join(tempDir, "src", "lib", "utils.ts"),
"export function cn(...inputs: unknown[]) {\n return inputs\n}\n",
"utf-8"
)
const config = await getConfig(tempDir)
if (!config) {
throw new Error("Failed to get config")
}
const buttonFile = {
path: "registry/default/ui/button.tsx",
type: "registry:ui" as const,
content: `import { cn } from "@/lib/utils"
export function Button() {
return <button>{cn("button")}</button>
}
`,
}
await updateFiles([buttonFile], config, {
overwrite: true,
silent: true,
})
vi.mocked(prompts).mockClear()
const result = await updateFiles([buttonFile], config, {
overwrite: false,
silent: true,
})
expect(result.filesSkipped).toEqual(["src/components/ui/button.tsx"])
expect(result.filesUpdated).toEqual([])
expect(vi.mocked(prompts)).not.toHaveBeenCalled()
} finally {
writeFileMock.mockResolvedValue(undefined)
await fsActual.rm(tempDir, { recursive: true, force: true }).catch(() => {})
}
})
test("should mark .env file as created when it doesn't exist", async () => {
const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
@@ -1099,6 +1392,48 @@ ANOTHER_NEW_KEY=another_value`,
expect(result.filesUpdated).not.toContain(".env")
})
test("should rewrite app-local files to workspace utils aliases in monorepos without tsconfig paths", async () => {
const config = await getConfig(
path.resolve(
__dirname,
"../../fixtures/frameworks/vite-monorepo-imports/apps/web"
)
)
if (!config) {
throw new Error("Failed to get monorepo app config")
}
const result = await updateFiles(
[
{
path: "registry/components/login-form.tsx",
type: "registry:component",
content: `import { cn } from "@/lib/utils"
export function LoginForm() {
return <div>{cn("login")}</div>
}
`,
},
],
config,
{
overwrite: true,
silent: true,
}
)
expect(result.filesCreated).toContain("src/components/login-form.tsx")
const writtenContent = (fs.writeFile as any).mock.calls.find((call: any) =>
call[0].endsWith("src/components/login-form.tsx")
)?.[1]
expect(writtenContent).toContain(`from "@workspace/ui/lib/utils"`)
expect(writtenContent).not.toContain(`from "#lib/utils"`)
})
test("should mark .env file as updated when merging content", async () => {
const tempDir = path.join(
path.resolve(__dirname, "../../fixtures"),
@@ -1968,4 +2303,73 @@ describe("toAliasedImport", () => {
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe("@/pages/home")
})
test("should preserve extensions for package imports that target bare wildcards", () => {
const filePath = "src/components/ui/button.tsx"
const config = {
resolvedPaths: {
cwd: path.resolve(__dirname, "../../fixtures/config-imports"),
components: path.resolve(
__dirname,
"../../fixtures/config-imports/src/components"
),
ui: path.resolve(
__dirname,
"../../fixtures/config-imports/src/components/ui"
),
},
aliases: {
components: "#components",
ui: "#components/ui",
},
}
const projectInfo = {
aliasPrefix: "#",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe(
"#components/ui/button.tsx"
)
})
test("should strip extensions for package imports whose target already includes them", () => {
const filePath = "src/components/button.tsx"
const config = {
resolvedPaths: {
cwd: path.resolve(__dirname, "../../fixtures/with-package-imports"),
components: path.resolve(
__dirname,
"../../fixtures/with-package-imports/src/components"
),
},
aliases: {
components: "#components-ext",
},
}
const projectInfo = {
aliasPrefix: "#",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe(
"#components-ext/button"
)
})
test("should keep exact package import aliases for index files", () => {
const filePath = "src/hooks/index.ts"
const config = {
resolvedPaths: {
cwd: path.resolve(__dirname, "../../fixtures/config-imports"),
hooks: path.resolve(__dirname, "../../fixtures/config-imports/src/hooks"),
},
aliases: {
hooks: "#hooks",
},
}
const projectInfo = {
aliasPrefix: "#",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe("#hooks")
})
})

View File

@@ -0,0 +1,18 @@
{
"style": "new-york",
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true
},
"rsc": false,
"tsx": true,
"aliases": {
"components": "#components",
"ui": "@workspace/ui/components",
"lib": "#lib",
"hooks": "#hooks",
"utils": "@workspace/ui/lib/utils"
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "web",
"private": true,
"type": "module",
"imports": {
"#*": "./src/*"
},
"dependencies": {
"@workspace/ui": "workspace:*",
"tailwindcss": "^4.2.1"
}
}

View File

@@ -0,0 +1 @@
console.log("web")

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolvePackageJsonImports": true
}
}

View File

@@ -0,0 +1,3 @@
import { defineConfig } from "vite"
export default defineConfig({})

Some files were not shown because too many files have changed in this diff Show More