mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-13 02:41:34 +00:00
Compare commits
28 Commits
shadcn@4.2
...
shadcn/pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecbace99d9 | ||
|
|
84d1d476b1 | ||
|
|
fc62d5781d | ||
|
|
d86c5e5939 | ||
|
|
8006dd1c93 | ||
|
|
1dcbb4c88a | ||
|
|
4f4ffde4aa | ||
|
|
6d7a0ed93b | ||
|
|
b909b0363f | ||
|
|
a6fa6893eb | ||
|
|
561586bd98 | ||
|
|
7ddb30aade | ||
|
|
024425d45a | ||
|
|
4bdaf48f9b | ||
|
|
e9546e87ff | ||
|
|
0b34d581f9 | ||
|
|
5c2ed5e90e | ||
|
|
e9443ccd4a | ||
|
|
1fe0fe65e8 | ||
|
|
6823bad998 | ||
|
|
398e6c3406 | ||
|
|
710cc27de7 | ||
|
|
08212a478d | ||
|
|
50dc9b506b | ||
|
|
6b5aa16668 | ||
|
|
706806a207 | ||
|
|
8a7502d7fa | ||
|
|
b57e192965 |
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
200
apps/v4/app/(app)/create/components/open-preset.tsx
Normal file
200
apps/v4/app/(app)/create/components/open-preset.tsx
Normal 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
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
81
apps/v4/app/(app)/create/hooks/use-open-preset.tsx
Normal file
81
apps/v4/app/(app)/create/hooks/use-open-preset.tsx
Normal 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,
|
||||
}
|
||||
}
|
||||
17
apps/v4/app/(app)/create/lib/parse-preset-input.test.ts
Normal file
17
apps/v4/app/(app)/create/lib/parse-preset-input.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
15
apps/v4/app/(app)/create/lib/parse-preset-input.ts
Normal file
15
apps/v4/app/(app)/create/lib/parse-preset-input.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
21
apps/v4/content/docs/changelog/2026-04-shadcn-apply.mdx
Normal file
21
apps/v4/content/docs/changelog/2026-04-shadcn-apply.mdx
Normal 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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. It’s 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
473
packages/shadcn/src/colors.ts
Normal file
473
packages/shadcn/src/colors.ts
Normal 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()
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
101
packages/shadcn/src/preset/defaults.ts
Normal file
101
packages/shadcn/src/preset/defaults.ts
Normal 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
|
||||
}
|
||||
>
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
275
packages/shadcn/src/preset/resolve.test.ts
Normal file
275
packages/shadcn/src/preset/resolve.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
773
packages/shadcn/src/preset/resolve.ts
Normal file
773
packages/shadcn/src/preset/resolve.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user