Compare commits

..

28 Commits

Author SHA1 Message Date
shadcn
ecbace99d9 feat: shadcn info preset code 2026-04-27 11:05:25 +04:00
shadcn
84d1d476b1 Merge pull request #9728 from htmujahid/main
Update URL for @shadcn-editor in registries.json
2026-04-14 21:03:02 +04:00
shadcn
fc62d5781d Merge pull request #10337 from ramonclaudio/docs/llms-txt-drift
docs(llms.txt): fix 404 and backfill missing routes
2026-04-09 05:13:18 +04:00
shadcn
d86c5e5939 Merge pull request #9484 from ramonclaudio/fix/docs-copy-page-components-list
fix(docs): replace <ComponentsList /> in copy-page and markdown output
2026-04-08 22:02:29 +04:00
shadcn
8006dd1c93 Merge branch 'main' into fix/docs-copy-page-components-list 2026-04-08 21:43:08 +04:00
Ray
1dcbb4c88a docs(llms.txt): fix 404 and backfill missing routes
llms.txt was added in #8460 and hasn't kept up with the docs tree.
Audited every URL against apps/v4/content/docs and fixed the drift
in one pass.

Removed:
- About (/docs/about): returns 404, no about.mdx exists
- Form (/docs/components/form): points at a phantom. No radix/form.mdx
  exists post-#9304. URL only resolves because of a redirect in
  next.config.mjs, which lands at /docs/forms. That page is already
  listed as 'Forms Overview' in the ## Forms section, and the real
  form library docs (React Hook Form, TanStack Form, Next.js) are
  listed there too. The Form component entry is a stale duplicate.

Added to Overview:
- Skills (/docs/skills)
- Directory (/docs/directory)

Added whole RTL section (new since #8460):
- RTL (/docs/rtl)
- RTL - Next.js
- RTL - Vite
- RTL - TanStack Start

Added to Components:
- Direction (Misc)
- Native Select (Form & Input, after Select)
- Sonner (Feedback & Status, after Toast, since Sonner has its own
  docs page even though Toast already uses it under the hood)

Added to Registry:
- Namespaces
- Add a Registry (open source registry index)
- Open in v0 integration
- registry.json schema docs
- registry-item.json spec docs

Descriptions match the short curated style of the rest of the file.
Noticed while working on #9484.
2026-04-08 12:36:44 -04:00
shadcn
4f4ffde4aa chore: update registries 2026-04-08 20:01:13 +04:00
Ray
6d7a0ed93b fix(docs): replace <ComponentsList /> in copy-page and markdown output
The <ComponentsList /> tag on /docs/components was emitted as-is by
the Copy Page button and the /llm/[slug] markdown endpoint because
getComponentsList() walked components.children for pages directly.
After #9304 restructured the folder into components/radix/ and
components/base/ subfolders, the filter always returned [] and the
tag was replaced with an empty string (or, in the copy-page case,
never replaced at all).

- Reuse getPagesFromFolder() from lib/page-tree so the walker stays
  in sync with the on-screen ComponentsList React component.
- Match the existing llms.txt format: flat absolute URLs (the
  /docs/components/:name -> /docs/components/radix/:name redirect in
  next.config.mjs is the canonical form) plus the frontmatter
  description pulled via source.getPage() on each page.
- Export replaceComponentsList() and call it from
  docs/[[...slug]]/page.tsx so the Copy Page button goes through the
  same replacement path as processMdxForLLMs.
2026-04-08 11:50:07 -04:00
shadcn
b909b0363f Merge pull request #10324 from wrappixelTeam/feat/added-shadcn-dashboard
feat(registry): added new registry ( @shadcn-dashboard )
2026-04-08 19:15:16 +04:00
shadcn
a6fa6893eb Merge pull request #10333 from kapishdima/feat/remocn
feat: added @remocn to directory.json
2026-04-08 19:08:48 +04:00
KapishDima
561586bd98 Merge branch 'main' into feat/remocn 2026-04-08 16:41:56 +03:00
kapishdima
7ddb30aade feat: added @remocn to directory.json 2026-04-08 16:38:33 +03:00
shadcn
024425d45a fix: directory pager 2026-04-08 17:05:50 +04:00
Mihir Koshti
4bdaf48f9b Merge branch 'main' into feat/added-shadcn-dashboard 2026-04-08 18:15:55 +05:30
shadcn
e9546e87ff Merge pull request #10332 from shadcn-ui/shadcn/open-preset
feat: add open preset
2026-04-08 16:43:39 +04:00
shadcn
0b34d581f9 feat: add open preset 2026-04-08 16:33:32 +04:00
shadcn
5c2ed5e90e Merge branch 'main' of github.com:shadcn-ui/ui 2026-04-08 14:42:57 +04:00
shadcn
e9443ccd4a docs: add apply changelog 2026-04-08 14:42:52 +04:00
shadcn
1fe0fe65e8 Merge pull request #10331 from shadcn-ui/shadcn/directory-refactor
refactor: directory
2026-04-08 12:31:58 +04:00
shadcn
6823bad998 refactor: directory 2026-04-08 12:23:34 +04:00
shadcn
398e6c3406 fix: formatting in registries.json 2026-04-08 11:52:03 +04:00
shadcn
710cc27de7 Merge pull request #10330 from Aniket-508/feat/termcn-directory
feat(registry): add @termcn
2026-04-08 10:42:26 +04:00
Aniket Pawar
08212a478d feat(registry): add @termcn 2026-04-08 02:47:14 +00:00
htmujahid
50dc9b506b fix: update URL for @shadcn-editor to point to raw GitHub content 2026-04-07 19:10:12 +05:00
Mihir Koshti
6b5aa16668 updated name to @shadcndashboard 2026-04-07 18:31:07 +05:30
Mihir Koshti
706806a207 feat(registry): added new registry ( @shadcn-dashboard ) 2026-04-07 18:07:26 +05:30
Talha Mujahid
8a7502d7fa Merge branch 'shadcn-ui:main' into main 2026-04-07 17:34:11 +05:00
Talha Mujahid
b57e192965 Update URL for @shadcn-editor in registries.json 2026-02-25 08:01:05 +05:00
108 changed files with 2814 additions and 4530 deletions

View File

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

View File

@@ -10,6 +10,7 @@ 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) {
@@ -32,12 +33,13 @@ 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>{hasCopied ? "Copied" : `--preset ${presetCode}`}</span>
<span className="block min-w-0 truncate">{label}</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,8 +102,12 @@ 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="flex-1 md:flex-none" />
<RandomButton className="flex-1 md:flex-none" />
<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" />
<ActionMenu itemsByBase={itemsByBase} />
<ResetDialog />
</CardFooter>

View File

@@ -17,6 +17,7 @@ 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"
@@ -27,6 +28,7 @@ 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()
@@ -55,6 +57,9 @@ 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

@@ -0,0 +1,200 @@
"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,6 +11,7 @@ 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,
@@ -20,78 +21,6 @@ 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)
@@ -117,6 +46,89 @@ 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 text-center font-medium">Shuffle</span>
<span className="w-full truncate text-center font-medium">Shuffle</span>
</Button>
)
}

View File

@@ -0,0 +1,81 @@
"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

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,15 @@
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,6 +4,7 @@ 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"
@@ -83,7 +84,7 @@ export default async function Page(props: {
const neighbours = isChangelog
? { previous: null, next: null }
: findNeighbour(source.pageTree, page.url)
const raw = await page.data.getText("raw")
const raw = replaceComponentsList(await page.data.getText("raw"))
return (
<div

View File

@@ -10,6 +10,7 @@ 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 {
@@ -139,6 +140,7 @@ 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 "@/registry/new-york-v4/ui/button"
import { Button } from "@/styles/base-nova/ui/button"
import {
Dialog,
DialogClose,
@@ -15,8 +15,7 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/registry/new-york-v4/ui/dialog"
} from "@/styles/base-nova/ui/dialog"
import {
Drawer,
DrawerClose,
@@ -25,42 +24,74 @@ import {
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/registry/new-york-v4/ui/drawer"
} from "@/styles/base-nova/ui/drawer"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/registry/new-york-v4/ui/tabs"
} from "@/styles/base-nova/ui/tabs"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/registry/new-york-v4/ui/tooltip"
} from "@/styles/base-nova/ui/tooltip"
const DirectoryAddContext = React.createContext<{
open: (registry: { name: string }) => void
}>({
open: () => {},
})
export function useDirectoryAdd() {
return React.useContext(DirectoryAddContext)
}
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 [open, setOpen] = React.useState(false)
const [isOpen, setIsOpen] = React.useState(false)
const [selectedRegistry, setSelectedRegistry] = React.useState<{
name: string
} | null>(null)
const isMobile = useIsMobile()
const packageManager = config.packageManager || "pnpm"
const commands = React.useMemo(() => {
if (!selectedRegistry) return null
return {
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}`,
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}`,
}
}, [registry.name])
}, [selectedRegistry])
const command = commands[packageManager]
const command = commands?.[packageManager] ?? ""
React.useEffect(() => {
if (hasCopied) {
@@ -74,19 +105,23 @@ export function DirectoryAddButton({
name: "copy_registry_add_command",
properties: {
command,
registry: registry.name,
registry: selectedRegistry?.name ?? "",
},
})
setHasCopied(true)
}, [command, registry.name])
}, [command, selectedRegistry?.name])
const Trigger = (
<Button size="sm" variant="outline" className="relative z-10">
Add <IconPlus />
</Button>
const contextValue = React.useMemo(
() => ({
open: (registry: { name: string }) => {
setSelectedRegistry(registry)
setIsOpen(true)
},
}),
[]
)
const Content = (
const Content = commands ? (
<Tabs
value={packageManager}
onValueChange={(value) => {
@@ -95,30 +130,28 @@ export function DirectoryAddButton({
packageManager: value as "pnpm" | "npm" | "yarn" | "bun",
})
}}
className="gap-0 overflow-hidden rounded-lg border"
className="gap-0 overflow-hidden rounded-xl border"
>
<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">
<div className="flex items-center gap-2 border-b p-1.5">
<TabsList className="h-auto *:data-[slot=tabs-trigger]:pt-0">
<TabsTrigger value="pnpm">pnpm</TabsTrigger>
<TabsTrigger value="npm">npm</TabsTrigger>
<TabsTrigger value="yarn">yarn</TabsTrigger>
<TabsTrigger value="bun">bun</TabsTrigger>
</TabsList>
<Tooltip>
<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
render={
<Button
size="icon-sm"
variant="ghost"
className="ml-auto"
onClick={handleCopy}
/>
}
>
{hasCopied ? <IconCheck /> : <IconCopy />}
<span className="sr-only">Copy command</span>
</TooltipTrigger>
<TooltipContent>
{hasCopied ? "Copied!" : "Copy command"}
@@ -135,47 +168,48 @@ export function DirectoryAddButton({
</TabsContent>
))}
</Tabs>
)
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>
)
}
) : null
return (
<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>
<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>
)
}

View File

@@ -1,12 +1,20 @@
"use client"
import * as React from "react"
import { IconArrowUpRight } from "@tabler/icons-react"
import { usePathname } from "next/navigation"
import {
IconArrowUpRight,
IconChevronLeft,
IconChevronRight,
} from "@tabler/icons-react"
import { cn } from "@/lib/utils"
import { useSearchRegistry } from "@/hooks/use-search-registry"
import { DirectoryAddButton } from "@/components/directory-add-button"
import globalRegistries from "@/registry/directory.json"
import { Button } from "@/registry/new-york-v4/ui/button"
import {
DirectoryAddButton,
DirectoryAddProvider,
} from "@/components/directory-add-button"
import { Button, buttonVariants } from "@/styles/base-nova/ui/button"
import {
Item,
ItemActions,
@@ -17,9 +25,16 @@ import {
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@/registry/new-york-v4/ui/item"
} 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"
import { SearchDirectory } from "./search-directory"
import { SearchDirectory } from "./directory-search"
function getHomepageUrl(homepage: string) {
const url = new URL(homepage)
@@ -29,55 +44,308 @@ 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 { registries } = useSearchRegistry()
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]
)
return (
<div className="mt-6">
<SearchDirectory />
<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" />
<ItemGroup className="my-8">
{registries.map((registry, index) => (
{Array.from({ length: 10 }, (_, index) => (
<React.Fragment key={index}>
<Item className="group/item relative gap-6 px-0">
<ItemMedia
variant="image"
dangerouslySetInnerHTML={{ __html: registry.logo }}
className="grayscale *:[svg]:size-8 *:[svg]:fill-foreground"
/>
<Item className="relative items-start gap-6 px-0">
<Skeleton className="size-8 rounded-lg" />
<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>
)}
<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" />
</ItemContent>
<ItemActions className="relative z-10 hidden self-start sm:flex">
<DirectoryAddButton registry={registry} />
<ItemActions className="hidden self-start sm:flex">
<Skeleton className="h-7 w-16 rounded-lg" />
</ItemActions>
<ItemFooter className="justify-start pl-16 sm:hidden">
<Button size="sm" variant="outline">
View <IconArrowUpRight />
</Button>
<DirectoryAddButton registry={registry} />
<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>
</Item>
{index < globalRegistries.length - 1 && (
<ItemSeparator className="my-1" />
)}
{index < 9 && <ItemSeparator className="my-1" />}
</React.Fragment>
))}
</ItemGroup>
</div>
</>
)
}

View File

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

View File

@@ -140,87 +140,15 @@ Setting this option to `false` allows components to be added as JavaScript with
## aliases
The CLI uses these values to place generated components in the correct location and rewrite imports.
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.
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`.
Path aliases have to be set up in your `tsconfig.json` or `jsconfig.json` file.
<Callout className="mt-6">
**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.
**Important:** If you're using the `src` directory, make sure it is included
under `paths` in your `tsconfig.json` or `jsconfig.json` file.
</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,14 +3,9 @@ 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
type="warning"
className="border-amber-200 bg-amber-50 font-semibold dark:border-amber-900 dark:bg-amber-950"
>
<Callout className="bg-muted font-semibold">
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,85 +164,3 @@ 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

@@ -0,0 +1,21 @@
---
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,11 +19,9 @@ Add the following dependencies to your project:
npm install shadcn class-variance-authority clsx tailwind-merge lucide-react tw-animate-css
```
### Configure import aliases
### Configure path aliases
Choose one of the following alias setups.
#### Option A: `tsconfig.json` paths
Configure the path aliases in your `tsconfig.json` file.
```json {3-6} title="tsconfig.json" showLineNumbers
{
@@ -36,31 +34,7 @@ Choose one of the following alias setups.
}
```
#### 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.
The `@` alias is a preference. You can use other aliases if you want.
### Configure styles
@@ -237,20 +211,6 @@ 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,7 +1,10 @@
import { debounce, useQueryState } from "nuqs"
import { debounce, parseAsInteger, 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("@", "")
@@ -25,15 +28,44 @@ const searchDirectory = (query: string | null) => {
return globalRegistries.filter((registry) => finderFn(registry, query))
}
export const useSearchRegistry = () => {
export function useSearchRegistry() {
const mounted = useMounted()
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 {
query,
registries: searchDirectory(query),
setQuery,
isLoading: !mounted,
query: currentQuery,
setQuery: (value: string | null) => {
setQuery(value)
setPage(null)
},
registries,
paginatedRegistries,
page: currentPage,
totalPages,
setPage,
}
}

View File

@@ -1,7 +1,9 @@
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"
@@ -35,28 +37,28 @@ function getRegistryEntry(name: string, styleName: string) {
)
}
function getComponentsList() {
const components = source.pageTree.children.find(
export function replaceComponentsList(content: string) {
const componentsFolder = source.pageTree.children.find(
(page) => page.$id === "components"
)
if (components?.type !== "folder") {
return ""
}
const list = components.children.filter(
(component) => component.type === "page"
)
return list
.map((component) => `- [${component.name}](${component.url})`)
.join("\n")
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)
}
export function processMdxForLLMs(content: string, style: Style["name"]) {
// Replace <ComponentsList /> with a markdown list of components.
const componentsListRegex = /<ComponentsList\s*\/>/g
content = content.replace(componentsListRegex, getComponentsList())
content = replaceComponentsList(content)
const componentPreviewRegex =
/<ComponentPreview[\s\S]*?name="([^"]+)"[\s\S]*?\/>/g

View File

@@ -9,7 +9,8 @@
- [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.
- [About](https://ui.shadcn.com/docs/about): Credits and project information.
- [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.
## Installation
@@ -28,7 +29,6 @@
### 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,6 +39,7 @@
- [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.
@@ -75,6 +76,7 @@
- [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.
@@ -100,6 +102,7 @@
- [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
@@ -109,6 +112,13 @@
- [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.
@@ -137,6 +147,11 @@
- [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://shadcn-editor.vercel.app/r/{name}.json",
"url": "https://raw.githubusercontent.com/htmujahid/shadcn-editor/refs/heads/main/public/r/{name}.json",
"description": "Accessible, Customizable, Rich Text Editor. Made with Lexical and Shadcn/UI. Open Source. Open Code."
},
{
@@ -809,12 +809,6 @@
"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",
@@ -1024,5 +1018,17 @@
"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

@@ -0,0 +1,473 @@
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,5 +1,6 @@
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 {
@@ -64,7 +65,7 @@ export const info = new Command()
const config = await getConfig(cwd)
const components = await getProjectComponents(cwd)
const base = getBase(config?.style)
const data = collectInfo(projectInfo, config, components, base)
const data = await collectInfo(projectInfo, config, components, base)
if (opts.json) {
console.log(JSON.stringify(data, null, 2))
@@ -91,12 +92,16 @@ function getRegistries(
return result
}
function collectInfo(
export async 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
? {
@@ -142,6 +147,7 @@ function collectInfo(
registries: getRegistries(config.registries),
}
: null,
preset,
components,
links: {
docs: `${SHADCN_URL}/docs`,
@@ -153,7 +159,7 @@ function collectInfo(
}
}
function printInfo(data: ReturnType<typeof collectInfo>) {
export function printInfo(data: Awaited<ReturnType<typeof collectInfo>>) {
// Project.
logger.log(highlighter.info("Project"))
if (data.project) {
@@ -187,6 +193,29 @@ function printInfo(data: 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,7 +21,6 @@ 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"
@@ -566,7 +565,6 @@ export async function runInit(
}
) {
let projectInfo
let projectConfig
let newProjectTemplate: keyof typeof templates | undefined
// Resolve the effective template if --monorepo is set.
@@ -608,8 +606,6 @@ 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
@@ -623,9 +619,6 @@ 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({
@@ -639,15 +632,15 @@ export async function runInit(
silent: options.silent,
})
if (templatePostInit) {
// Run postInit for newly scaffolded projects (e.g. git init).
await templatePostInit({ projectPath: options.cwd })
}
// Run postInit for new projects (e.g. git init).
await selectedTemplate.postInit({ 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))
@@ -777,9 +770,9 @@ export async function runInit(
options.isNewProject || projectInfo?.framework.name === "next-app",
})
// Run postInit only for newly scaffolded projects.
if (templatePostInit) {
await templatePostInit({ projectPath: options.cwd })
// Run postInit for new projects without a custom init (e.g. git init).
if (selectedTemplate) {
await selectedTemplate.postInit({ projectPath: options.cwd })
}
return fullConfig
@@ -863,6 +856,12 @@ 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",
@@ -877,16 +876,6 @@ 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,
@@ -900,11 +889,11 @@ async function promptForConfig(defaultConfig: Config | null = null) {
rsc: options.rsc,
tsx: options.typescript,
aliases: {
utils: options.utils,
components: options.components,
ui: aliasDefaults.ui,
lib: aliasDefaults.lib,
hooks: aliasDefaults.hooks,
utils: aliasDefaults.utils,
// TODO: fix this.
lib: options.components.replace(/\/components$/, "lib"),
hooks: options.components.replace(/\/components$/, "hooks"),
},
})
}

View File

@@ -1,140 +0,0 @@
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,6 +1,5 @@
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,
@@ -133,7 +132,6 @@ 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()
@@ -164,23 +162,14 @@ export async function preFlightInit(
if (errors[ERRORS.IMPORT_ALIAS_MISSING]) {
logger.break()
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.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.break()

View File

@@ -0,0 +1,101 @@
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,6 +79,12 @@ 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,99 +11,9 @@ 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 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 { DEFAULT_PRESETS } from "./defaults"
export function resolveCreateUrl(
searchParams?: Partial<{
@@ -188,7 +98,7 @@ export function resolveInitUrl(
radius: preset.radius,
})
if (preset.chartColor) {
if (preset.chartColor && preset.chartColor !== "neutral") {
params.set("chartColor", preset.chartColor)
}

View File

@@ -0,0 +1,275 @@
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

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

View File

@@ -1,77 +0,0 @@
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

@@ -1,62 +0,0 @@
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,13 +24,9 @@ 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 = {
@@ -148,19 +144,6 @@ 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]
@@ -220,25 +203,13 @@ 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, finalContent)) {
if (isContentSame(oldContent, content)) {
action = "skip"
} else {
action = "overwrite"
@@ -248,7 +219,7 @@ async function processFiles(
result.files.push({
path: relativePath,
action,
content: finalContent,
content,
...(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 { resolveImportWithMetadata } from "@/src/utils/resolve-import"
import { resolveImport } from "@/src/utils/resolve-import"
import { cosmiconfig } from "cosmiconfig"
import fg from "fast-glob"
import { loadConfig, type ConfigLoaderSuccessResult } from "tsconfig-paths"
import { loadConfig } from "tsconfig-paths"
import { z } from "zod"
export const DEFAULT_STYLE = "default"
@@ -64,37 +64,6 @@ 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: {
@@ -103,93 +72,35 @@ export async function resolveConfigPaths(
? path.resolve(cwd, config.tailwind.config)
: "",
tailwindCss: path.resolve(cwd, config.tailwind.css),
utils: resolvedUtils,
components: resolvedComponents,
ui: resolvedUi,
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"
),
// TODO: Make this configurable.
// For now, we assume the lib and hooks directories are one level up from the components directory.
lib: resolvedLib,
hooks: resolvedHooks,
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"
),
},
})
}
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> {
@@ -247,20 +158,7 @@ export async function getWorkspaceConfig(config: Config) {
continue
}
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
resolvedAliases[key] = await getConfig(packageRoot)
}
const result = workspaceConfigSchema.safeParse(resolvedAliases)

View File

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

View File

@@ -6,10 +6,6 @@ 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"
@@ -54,7 +50,7 @@ export async function getProjectInfo(
tailwindConfigFile,
tailwindCssFile,
tailwindVersion,
aliasPrefixInfo,
aliasPrefix,
packageJson,
] = await Promise.all([
fg.glob(
@@ -70,7 +66,7 @@ export async function getProjectInfo(
getTailwindConfigFile(cwd),
getTailwindCssFile(cwd, opts?.configCssFile),
getTailwindVersion(cwd),
getProjectAliasInfo(cwd),
getTsConfigAliasPrefix(cwd),
getPackageInfo(cwd, false),
])
@@ -87,7 +83,7 @@ export async function getProjectInfo(
tailwindCssFile,
tailwindVersion,
frameworkVersion: null,
aliasPrefix: aliasPrefixInfo.prefix,
aliasPrefix,
}
// Next.js.
@@ -304,62 +300,28 @@ 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 (!paths || !Object.entries(paths).length) {
if (
tsConfig?.resultType === "failed" ||
!Object.entries(tsConfig?.paths).length
) {
return null
}
// This assume that the first alias is the prefix.
for (const [alias, targets] of Object.entries(paths)) {
const values = Array.isArray(targets) ? targets : [targets]
for (const [alias, paths] of Object.entries(tsConfig.paths)) {
if (
values.includes("./*") ||
values.includes("./src/*") ||
values.includes("./app/*") ||
values.includes("./resources/js/*") // Laravel.
paths.includes("./*") ||
paths.includes("./src/*") ||
paths.includes("./app/*") ||
paths.includes("./resources/js/*") // Laravel.
) {
return alias.replace(/\/\*$/, "") ?? null
}
}
// Use the first alias as the prefix.
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,
}
return Object.keys(tsConfig?.paths)?.[0].replace(/\/\*$/, "") ?? null
}
export async function isTypeScriptProject(cwd: string) {
@@ -383,16 +345,10 @@ 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")
let parsed
try {
parsed = JSON.parse(stripJsonComments(contents))
} catch {
continue
}
const result = TS_CONFIG_SCHEMA.safeParse(parsed)
const cleanedContents = contents.replace(/\/\*\s*\*\//g, "")
const result = TS_CONFIG_SCHEMA.safeParse(JSON.parse(cleanedContents))
if (result.error) {
continue
@@ -404,89 +360,16 @@ 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, aliasInfo] = await Promise.all([
const [existingConfig, projectInfo] = await Promise.all([
getConfig(cwd),
!defaultProjectInfo
? getProjectInfo(cwd)
: Promise.resolve(defaultProjectInfo),
getProjectAliasInfo(cwd),
])
if (existingConfig) {
@@ -501,35 +384,6 @@ 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,
@@ -543,59 +397,18 @@ export async function getProjectConfig(
prefix: "",
},
iconLibrary: "lucide",
aliases,
aliases: {
components: `${projectInfo.aliasPrefix}/components`,
ui: `${projectInfo.aliasPrefix}/components/ui`,
hooks: `${projectInfo.aliasPrefix}/hooks`,
lib: `${projectInfo.aliasPrefix}/lib`,
utils: `${projectInfo.aliasPrefix}/lib/utils`,
},
}
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

@@ -1,156 +0,0 @@
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

@@ -1,185 +0,0 @@
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,139 +1,13 @@
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: ResolveImportConfig
config: Pick<ConfigLoaderSuccessResult, "absoluteBaseUrl" | "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)(
return 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,12 +9,10 @@ export const transformImport: Transformer = async ({
}) => {
const utilsAlias = config.aliases?.utils
const workspaceAlias =
typeof utilsAlias === "string"
? getWorkspaceAliasFromUtilsAlias(utilsAlias)
typeof utilsAlias === "string" && utilsAlias.includes("/")
? utilsAlias.split("/")[0]
: "@"
const utilsImport = workspaceAlias
? `${workspaceAlias}/lib/utils`
: "@/lib/utils"
const utilsImport = `${workspaceAlias}/lib/utils`
if (![".tsx", ".ts", ".jsx", ".js"].includes(sourceFile.getExtension())) {
return sourceFile
@@ -57,8 +55,6 @@ function updateImportAliases(
config: Config,
isRemote: boolean = false
) {
moduleSpecifier = normalizeImportSpecifier(moduleSpecifier, config)
// Not a local import.
if (!moduleSpecifier.startsWith("@/") && !isRemote) {
return moduleSpecifier
@@ -69,41 +65,9 @@ function updateImportAliases(
moduleSpecifier = moduleSpecifier.replace(/^@\//, `@/registry/new-york/`)
}
if (moduleSpecifier === "@/registry") {
return config.aliases.components
}
// Not a registry import.
if (!moduleSpecifier.startsWith("@/registry/")) {
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)
}
// We fix the alias and return.
const alias = config.aliases.components.split("/")[0]
return moduleSpecifier.replace(/^@\//, `${alias}/`)
}
@@ -115,13 +79,6 @@ function updateImportAliases(
)
}
if (
config.aliases.utils &&
moduleSpecifier.match(/^@\/registry\/(.+)\/lib\/utils$/)
) {
return config.aliases.utils
}
if (
config.aliases.components &&
moduleSpecifier.match(/^@\/registry\/(.+)\/components/)
@@ -154,71 +111,3 @@ 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,11 +15,7 @@ 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 { resolvePackageImport } from "@/src/utils/package-imports"
import {
isLocalAliasImport,
resolveImportWithMetadata,
} from "@/src/utils/resolve-import"
import { resolveImport } 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"
@@ -35,11 +31,9 @@ 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, type ConfigLoaderSuccessResult } from "tsconfig-paths"
import { loadConfig } from "tsconfig-paths"
import { z } from "zod"
const CODE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"]
export async function updateFiles(
files: RegistryItem["files"],
config: Config,
@@ -79,15 +73,6 @@ 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[] = []
@@ -191,19 +176,10 @@ 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, resolvedContent, {
isContentSame(existingFileContent, content, {
// Ignore import differences for workspace components.
// TODO: figure out if we always want this.
ignoreImports: options.isWorkspace,
@@ -578,175 +554,66 @@ 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,
})
if (rewrittenContent === content) {
continue
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)
}
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,
@@ -827,8 +694,7 @@ export function resolveModuleByProbablePath(
export function toAliasedImport(
filePath: string,
config: Config,
projectInfo: ProjectInfo,
importerPath?: string
projectInfo: ProjectInfo
): string | null {
const abs = path.normalize(path.join(config.resolvedPaths.cwd, filePath))
@@ -850,31 +716,10 @@ 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 keepExt = CODE_EXTENSIONS.includes(ext) ? "" : ext
const codeExts = [".ts", ".tsx", ".js", ".jsx"]
const keepExt = codeExts.includes(ext) ? "" : ext
let noExt = rel.slice(0, rel.length - ext.length)
// 4⃣ Collapse "/index" to its directory
@@ -883,138 +728,26 @@ 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

@@ -1,251 +0,0 @@
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

@@ -1,16 +0,0 @@
{
"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

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

View File

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

View File

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

View File

@@ -1,18 +0,0 @@
{
"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

@@ -1,11 +0,0 @@
{
"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

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

View File

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

View File

@@ -1,21 +0,0 @@
{
"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

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

View File

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

View File

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

View File

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

View File

@@ -1,25 +0,0 @@
{
"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

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +0,0 @@
{
"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

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

View File

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

View File

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

View File

@@ -1,18 +0,0 @@
{
"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

@@ -1,14 +0,0 @@
{
"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

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

View File

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

View File

@@ -1,13 +0,0 @@
{
"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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +0,0 @@
{
"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/ui/lib/utils")
const { cn } = await import("@workspace/lib/utils")
return cn
}
"

View File

@@ -1,9 +1,6 @@
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", () => ({
@@ -97,22 +94,9 @@ 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 {
@@ -424,244 +408,6 @@ 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,6 +1,4 @@
import os from "os"
import path from "path"
import fs from "fs-extra"
import { describe, expect, test } from "vitest"
import {
@@ -8,9 +6,7 @@ import {
getBase,
getConfig,
getRawConfig,
getWorkspaceConfig,
} from "../../src/utils/get-config"
import { getProjectConfig } from "../../src/utils/get-project-info"
test("get raw config", async () => {
expect(
@@ -40,164 +36,6 @@ 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"))
@@ -358,282 +196,6 @@ 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,62 +48,6 @@ 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,14 +29,6 @@ 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,12 +1,8 @@
import path from "path"
import { loadConfig, type ConfigLoaderSuccessResult } from "tsconfig-paths"
import { describe, expect, test } from "vitest"
import { expect, test } from "vitest"
import {
isLocalAliasImport,
resolveImport,
resolveImportWithMetadata,
} from "../../src/utils/resolve-import"
import { resolveImport } from "../../src/utils/resolve-import"
test("resolve import", async () => {
expect(
@@ -83,114 +79,3 @@ 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,49 +34,6 @@ 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({
@@ -219,53 +176,6 @@ 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({
@@ -318,160 +228,6 @@ 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,7 +1,6 @@
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 {
@@ -1074,298 +1073,6 @@ 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")
@@ -1392,48 +1099,6 @@ 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"),
@@ -2303,73 +1968,4 @@ 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

@@ -1,18 +0,0 @@
{
"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

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

View File

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

View File

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

View File

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

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