snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
All checks were successful
Release / Create Version PR or Publish Stable Release (push) Has been skipped
Release / Publish Prerelease to NPM (push) Has been skipped
Monitor Registries / Monitor registry health (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 22:44:43 +05:00
commit 1416db152c
5727 changed files with 630238 additions and 0 deletions

3
packages/shadcn/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
components
dist
.turbo

View File

@@ -0,0 +1 @@
test

1025
packages/shadcn/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

61
packages/shadcn/README.md Normal file
View File

@@ -0,0 +1,61 @@
# shadcn
A CLI for adding components to your project.
## create
Use the `create` command to create a new project. You will be taken to a website to build your custom design system and choose your framework.
```bash
npx shadcn create
```
## init
Use the `init` command to initialize dependencies for a new project.
The `init` command installs dependencies, adds the `cn` util, configures Tailwind CSS, and CSS variables for the project.
```bash
npx shadcn init
```
## apply
Use the `apply` command to apply a preset to an existing project.
The `apply` command overwrites the current preset configuration, reinstalls detected UI components, and updates fonts and CSS variables to match the new preset.
```bash
npx shadcn apply --preset a2r6bw
```
## add
Use the `add` command to add components to your project.
The `add` command adds a component to your project and installs all required dependencies.
```bash
npx shadcn add [component]
```
### Example
```bash
npx shadcn add alert-dialog
```
You can also run the command without any arguments to view a list of all available components:
```bash
npx shadcn add
```
## Documentation
Visit https://ui.shadcn.com/docs/cli to view the documentation.
## License
Licensed under the [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).

View File

@@ -0,0 +1,135 @@
{
"name": "shadcn",
"version": "4.21.0",
"description": "Add components to your apps.",
"homepage": "https://ui.shadcn.com",
"publishConfig": {
"access": "public"
},
"license": "MIT",
"author": {
"name": "shadcn",
"url": "https://twitter.com/shadcn"
},
"repository": {
"type": "git",
"url": "https://github.com/shadcn-ui/ui.git",
"directory": "packages/shadcn"
},
"files": [
"dist"
],
"keywords": [
"components",
"ui",
"tailwind",
"radix-ui",
"base-ui",
"shadcn"
],
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./registry": {
"types": "./dist/registry/index.d.ts",
"default": "./dist/registry/index.js"
},
"./schema": {
"types": "./dist/schema/index.d.ts",
"default": "./dist/schema/index.js"
},
"./mcp": {
"types": "./dist/mcp/index.d.ts",
"default": "./dist/mcp/index.js"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"default": "./dist/utils/index.js"
},
"./icons": {
"types": "./dist/icons/index.d.ts",
"default": "./dist/icons/index.js"
},
"./preset": {
"types": "./dist/preset/index.d.ts",
"default": "./dist/preset/index.js"
},
"./tailwind.css": "./dist/tailwind.css"
},
"sideEffects": [
"./dist/tailwind.css"
],
"bin": "./dist/index.js",
"engines": {
"node": ">=20.18.1"
},
"scripts": {
"dev": "tsup --watch",
"build": "tsup",
"typecheck": "tsc --noEmit",
"clean": "rimraf dist && rimraf components",
"start:dev": "cross-env REGISTRY_URL=http://localhost:4000/r SHADCN_TEMPLATE_DIR=../../templates node dist/index.js",
"start:prod": "cross-env REGISTRY_URL=https://ui.shadcn.com/r node dist/index.js",
"start": "node dist/index.js",
"format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
"format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache",
"release": "changeset version",
"pub:beta": "pnpm build && pnpm publish --no-git-checks --access public --tag beta",
"pub:rc": "pnpm build && pnpm publish --no-git-checks --access public --tag rc",
"pub:next": "pnpm build && pnpm publish --no-git-checks --access public --tag next",
"pub:release": "pnpm build && pnpm publish --access public",
"test": "vitest run",
"test:dev": "REGISTRY_URL=http://localhost:4000/r SHADCN_TEMPLATE_DIR=../../templates vitest run",
"mcp:inspect": "pnpm dlx @modelcontextprotocol/inspector node dist/index.js mcp"
},
"dependencies": {
"@babel/core": "^7.28.0",
"@babel/parser": "^7.28.0",
"@babel/plugin-transform-typescript": "^7.28.0",
"@babel/preset-typescript": "^7.27.1",
"@dotenvx/dotenvx": "^1.48.4",
"@modelcontextprotocol/sdk": "^1.26.0",
"@types/validate-npm-package-name": "^4.0.2",
"browserslist": "^4.26.2",
"cn": "^0.2.4",
"commander": "^14.0.0",
"cosmiconfig": "^9.0.0",
"dedent": "^1.6.0",
"deepmerge": "^4.3.1",
"diff": "^8.0.2",
"execa": "^9.6.0",
"fast-glob": "^3.3.3",
"fs-extra": "^11.3.1",
"fuzzysort": "^3.1.0",
"kleur": "^4.1.5",
"open": "^11.0.0",
"ora": "^8.2.0",
"postcss": "^8.5.23",
"postcss-selector-parser": "^7.1.3",
"prompts": "^2.4.2",
"recast": "^0.23.11",
"socks": "^2.8.8",
"stringify-object": "^5.0.0",
"ts-morph": "^26.0.0",
"tsconfig-paths": "^4.2.0",
"undici": "^7.29.0",
"validate-npm-package-name": "^7.0.1",
"zod": "^3.24.1",
"zod-to-json-schema": "^3.24.6"
},
"devDependencies": {
"@antfu/ni": "^25.0.0",
"@types/babel__core": "^7.20.5",
"@types/fs-extra": "^11.0.4",
"@types/prompts": "^2.4.9",
"@types/stringify-object": "^4.0.5",
"msw": "^2.10.4",
"rimraf": "^6.0.1",
"tsup": "^8.5.0",
"type-fest": "^4.41.0",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,470 @@
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]
export const TAILWIND_COLORS: Record<
TailwindColorFamily,
Record<TailwindColorScale, string>
> = {
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)",
},
}
// Old preset codes only carried one theme value, so these legacy chart HSLs
// should resolve to a neutral fallback family instead of a separate theme.
const LEGACY_COLOR_FAMILY_ALIASES: Record<string, TailwindColorFamily> = {
"220.9 39.3% 11%": "gray",
"210 20% 98%": "gray",
"12 76% 61%": "gray",
"220 70% 50%": "gray",
}
const TAILWIND_COLOR_VALUE_TO_FAMILY = new Map<string, TailwindColorFamily>()
for (const family of TAILWIND_COLOR_FAMILIES) {
for (const scale of TAILWIND_COLOR_SCALES) {
TAILWIND_COLOR_VALUE_TO_FAMILY.set(
normalizeColorValue(TAILWIND_COLORS[family][scale]),
family
)
}
}
export function findTailwindColorFamily(value: string | undefined) {
const normalized = normalizeColorValue(value)
if (!normalized) {
return null
}
return (
TAILWIND_COLOR_VALUE_TO_FAMILY.get(normalized) ??
LEGACY_COLOR_FAMILY_ALIASES[normalized] ??
null
)
}
export function normalizeColorValue(value: string | undefined) {
if (!value) {
return ""
}
const normalized = value.trim().replace(/\s+/g, " ").toLowerCase()
if (!normalized.startsWith("oklch(") || !normalized.endsWith(")")) {
return normalized
}
const inner = normalized.slice(6, -1).trim()
const [color] = inner.split(/\s*\/\s*/)
const parts = color.split(/\s+/)
if (parts.length < 3) {
return normalized
}
const lightness = normalizeColorNumber(parts[0], { percentage: true })
const chroma = normalizeColorNumber(parts[1])
const hue = normalizeColorNumber(parts[2])
return `oklch(${lightness} ${chroma} ${hue})`
}
function normalizeColorNumber(
value: string,
options: {
percentage?: boolean
} = {}
) {
if (options.percentage && value.endsWith("%")) {
return formatColorNumber(Number.parseFloat(value) / 100)
}
return formatColorNumber(Number.parseFloat(value))
}
function formatColorNumber(value: number) {
if (Number.isNaN(value)) {
return ""
}
return Number(value.toFixed(12)).toString()
}

View File

@@ -0,0 +1,739 @@
import path from "path"
import { runInit } from "@/src/commands/init"
import { REGISTRY_URL, SHADCN_URL } from "@/src/registry/constants"
import { getFixturesDir, withTempDir } from "@/src/test-helpers"
import { addComponents } from "@/src/utils/add-components"
import { createProject } from "@/src/utils/create-project"
import { dryRunComponents } from "@/src/utils/dry-run"
import { updateAppIndex } from "@/src/utils/update-app-index"
import fs from "fs-extra"
import { http, HttpResponse } from "msw"
import { setupServer } from "msw/node"
import prompts from "prompts"
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest"
import { add } from "./add"
// --- Mocked seams (the collaborators add.ts delegates to). ---
vi.mock("@/src/utils/add-components", () => ({
addComponents: vi.fn(),
}))
vi.mock("@/src/commands/init", () => ({
runInit: vi.fn(),
}))
vi.mock("@/src/utils/create-project", () => ({
createProject: vi.fn(),
}))
vi.mock("@/src/utils/update-app-index", () => ({
updateAppIndex: vi.fn(),
}))
vi.mock("@/src/utils/dry-run", () => ({
dryRunComponents: vi.fn(),
}))
vi.mock("prompts", () => ({
default: vi.fn(),
}))
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn(() => ({
start: vi.fn().mockReturnThis(),
stop: vi.fn(),
succeed: vi.fn(),
fail: vi.fn(),
info: vi.fn(),
})),
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
break: vi.fn(),
error: vi.fn(),
info: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
},
}))
vi.mock("@/src/utils/handle-error", () => ({
handleError: vi.fn((error) => {
throw error
}),
}))
// --- MSW registry server. ---
//
// Serves a minimal registry:ui item for most component names, plus
// dedicated handlers for the registry:style, registry:theme and
// universal (registry:item) branches, the /init endpoint hit by
// resolveRegistryBaseConfig, and a direct-URL v0 "chat" item for the
// updateAppIndex branch.
function uiItem(name: string) {
return {
name,
type: "registry:ui",
files: [
{
path: `registry/new-york/ui/${name}.tsx`,
type: "registry:ui",
content: `export function ${name}() {}`,
},
],
}
}
const CHAT_ITEM_URL = "https://v0.dev/chat/b/xyz"
// Registered under both "new-york" (used by fixtures with a non-empty
// tailwind.config, e.g. config-full) and "new-york-v4" (the fallback style
// resolved by configWithDefaults whenever tailwind.config is "", which is
// both createConfig's default and the vite-with-tailwind fixture's value).
const STYLES = ["new-york", "new-york-v4", "base-nova"]
function registerItem(name: string, body: Record<string, unknown>) {
return STYLES.map((style) =>
http.get(`${REGISTRY_URL}/styles/${style}/${name}.json`, () =>
HttpResponse.json(body)
)
)
}
const server = setupServer(
http.get(`${REGISTRY_URL}/index.json`, () =>
HttpResponse.json([
uiItem("button"),
uiItem("sonner"),
uiItem("toast"),
uiItem("toaster"),
])
),
...registerItem("button", uiItem("button")),
...registerItem("card", uiItem("card")),
...registerItem("sonner", uiItem("sonner")),
...registerItem("toast", uiItem("toast")),
...registerItem("toaster", uiItem("toaster")),
...registerItem("style-item", {
name: "style-item",
type: "registry:style",
}),
...registerItem("theme-item", {
name: "theme-item",
type: "registry:theme",
}),
...registerItem("universal-item", {
name: "universal-item",
type: "registry:item",
}),
http.get(`${SHADCN_URL}/init`, () =>
HttpResponse.json({
name: "base",
type: "registry:base",
extends: "none",
})
),
// resolveRegistryUrl appends "/json" for /chat/b/ URLs (the v0 registry
// convention), so the handler must match the rewritten address.
http.get(`${CHAT_ITEM_URL}/json`, () =>
HttpResponse.json({
name: "chat-item",
type: "registry:ui",
files: [],
})
)
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// --- Test helpers. ---
async function withFixtureCopy<T>(
fixtureName: string,
fn: (dir: string) => Promise<T>
) {
return withTempDir(async (dir) => {
await fs.copy(getFixturesDir(fixtureName), dir)
return fn(dir)
})
}
async function runAdd(args: string[], cwd: string) {
return add.parseAsync(["node", "shadcn", ...args, "--cwd", cwd], {
from: "node",
})
}
async function setConfigStyle(cwd: string, style: string) {
const configPath = path.join(cwd, "components.json")
const config = await fs.readJson(configPath)
await fs.writeJson(configPath, {
...config,
style,
})
}
function mockProcessExit() {
return vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit:${code}`)
}) as any
}
function mockPrompts(answers: Record<string, unknown>) {
vi.mocked(prompts).mockImplementation(async (opts: any) => {
const name = Array.isArray(opts) ? opts[0]?.name : opts?.name
if (name && name in answers) {
return { [name]: answers[name] }
}
return {}
})
}
function baseConfig(cwd: string) {
return {
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
css: "",
baseColor: "neutral",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
aliases: {
components: "@/components",
ui: "@/components/ui",
hooks: "@/hooks",
lib: "@/lib",
utils: "@/lib/utils",
},
registries: {},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: "",
utils: "",
components: "",
lib: "",
hooks: "",
ui: "",
},
} as any
}
describe("add command", () => {
let exit: ReturnType<typeof mockProcessExit>
beforeEach(() => {
vi.clearAllMocks()
exit = mockProcessExit()
mockPrompts({})
})
afterEach(() => {
exit.mockRestore()
})
describe("happy path", () => {
it("installs components via addComponents when a valid project exists", async () => {
await withFixtureCopy("config-full", async (cwd) => {
await runAdd(["button"], cwd)
expect(addComponents).toHaveBeenCalledTimes(1)
expect(addComponents).toHaveBeenCalledWith(
["button"],
expect.any(Object),
expect.any(Object)
)
expect(runInit).not.toHaveBeenCalled()
expect(createProject).not.toHaveBeenCalled()
})
})
})
describe("dry run", () => {
beforeEach(() => {
vi.mocked(dryRunComponents).mockResolvedValue({
files: [],
dependencies: [],
devDependencies: [],
css: null,
envVars: null,
fonts: [],
docs: null,
})
})
it("previews with --dry-run and never calls addComponents", async () => {
await withFixtureCopy("config-full", async (cwd) => {
await runAdd(["button", "--dry-run"], cwd)
expect(dryRunComponents).toHaveBeenCalledTimes(1)
expect(addComponents).not.toHaveBeenCalled()
})
})
it("implies dry-run with --diff", async () => {
await withFixtureCopy("config-full", async (cwd) => {
await runAdd(["button", "--diff"], cwd)
expect(dryRunComponents).toHaveBeenCalledTimes(1)
expect(addComponents).not.toHaveBeenCalled()
})
})
it("implies dry-run with --view", async () => {
await withFixtureCopy("config-full", async (cwd) => {
await runAdd(["button", "--view"], cwd)
expect(dryRunComponents).toHaveBeenCalledTimes(1)
expect(addComponents).not.toHaveBeenCalled()
})
})
})
describe("missing components.json (init flow)", () => {
it("runs init with the documented flags when the user proceeds", async () => {
await withTempDir(async (cwd) => {
await fs.writeJson(cwd + "/package.json", {
name: "no-config-project",
version: "1.0.0",
})
mockPrompts({
proceed: true,
base: "radix",
selectedPreset: "nova",
})
vi.mocked(runInit).mockResolvedValue(baseConfig(cwd))
await runAdd(["button"], cwd)
expect(runInit).toHaveBeenCalledTimes(1)
const callArgs = vi.mocked(runInit).mock.calls[0][0]
expect(callArgs.cwd).toBe(cwd)
expect(callArgs.yes).toBe(true)
expect(callArgs.force).toBe(true)
expect(callArgs.isNewProject).toBe(false)
expect(callArgs.skipPreflight).toBe(false)
// initHasRun guard: addComponents must NOT run again after init.
expect(addComponents).not.toHaveBeenCalled()
})
})
it("exits 1 without running init when the user declines", async () => {
await withTempDir(async (cwd) => {
await fs.writeJson(cwd + "/package.json", {
name: "no-config-project",
version: "1.0.0",
})
mockPrompts({ proceed: false })
await expect(runAdd(["button"], cwd)).rejects.toThrow("process.exit:1")
expect(runInit).not.toHaveBeenCalled()
})
})
it("allows toast after selecting Base UI", async () => {
await withTempDir(async (cwd) => {
await fs.writeJson(path.join(cwd, "package.json"), {
name: "no-config-project",
version: "1.0.0",
})
mockPrompts({
proceed: true,
base: "base",
selectedPreset: "nova",
})
vi.mocked(runInit).mockResolvedValue({
...baseConfig(cwd),
style: "base-nova",
})
await runAdd(["toast"], cwd)
expect(runInit).toHaveBeenCalledWith(
expect.objectContaining({
components: expect.arrayContaining(["toast"]),
})
)
})
})
it("warns about toast after selecting a non-Base UI library", async () => {
await withTempDir(async (cwd) => {
await fs.writeJson(path.join(cwd, "package.json"), {
name: "no-config-project",
version: "1.0.0",
})
const { logger } = await import("@/src/utils/logger")
mockPrompts({
proceed: true,
base: "radix",
})
await expect(runAdd(["toast"], cwd)).rejects.toThrow("process.exit:1")
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("only available for Base UI projects")
)
expect(runInit).not.toHaveBeenCalled()
})
})
})
describe("empty/missing project (create-project flow)", () => {
it("does not scaffold before warning about toast for a non-Base UI library", async () => {
await withTempDir(async (emptyDir) => {
const { logger } = await import("@/src/utils/logger")
mockPrompts({
base: "radix",
})
await expect(runAdd(["toast"], emptyDir)).rejects.toThrow(
"process.exit:1"
)
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("only available for Base UI projects")
)
expect(createProject).not.toHaveBeenCalled()
expect(runInit).not.toHaveBeenCalled()
})
})
it("runs init with isNewProject/skipPreflight after scaffolding a project", async () => {
await withTempDir(async (emptyDir) => {
await withTempDir(async (projectPath) => {
mockPrompts({
base: "radix",
selectedPreset: "nova",
})
vi.mocked(createProject).mockResolvedValue({
projectPath,
projectName: "app",
template: "next",
} as any)
vi.mocked(runInit).mockResolvedValue(baseConfig(projectPath))
await runAdd(["button"], emptyDir)
expect(createProject).toHaveBeenCalledTimes(1)
expect(runInit).toHaveBeenCalledTimes(1)
const callArgs = vi.mocked(runInit).mock.calls[0][0]
expect(callArgs.cwd).toBe(projectPath)
expect(callArgs.yes).toBe(true)
expect(callArgs.force).toBe(true)
expect(callArgs.isNewProject).toBe(true)
expect(callArgs.skipPreflight).toBe(true)
expect(addComponents).not.toHaveBeenCalled()
})
})
})
it("exits 1 when createProject cannot resolve a project path", async () => {
await withTempDir(async (emptyDir) => {
vi.mocked(createProject).mockResolvedValue({
projectPath: null,
projectName: null,
template: "next",
} as any)
await expect(runAdd(["button"], emptyDir)).rejects.toThrow(
"process.exit:1"
)
expect(runInit).not.toHaveBeenCalled()
})
})
it("updates the app index for a single v0 chat component", async () => {
await withTempDir(async (emptyDir) => {
await withTempDir(async (projectPath) => {
mockPrompts({
base: "radix",
selectedPreset: "nova",
})
vi.mocked(createProject).mockResolvedValue({
projectPath,
projectName: "app",
template: "next",
} as any)
vi.mocked(runInit).mockResolvedValue(baseConfig(projectPath))
await runAdd([CHAT_ITEM_URL], emptyDir)
expect(updateAppIndex).toHaveBeenCalledTimes(1)
expect(updateAppIndex).toHaveBeenCalledWith(
CHAT_ITEM_URL,
expect.any(Object)
)
})
})
})
it("does not update the app index for multiple components", async () => {
await withTempDir(async (emptyDir) => {
await withTempDir(async (projectPath) => {
mockPrompts({
base: "radix",
selectedPreset: "nova",
})
vi.mocked(createProject).mockResolvedValue({
projectPath,
projectName: "app",
template: "next",
} as any)
vi.mocked(runInit).mockResolvedValue(baseConfig(projectPath))
await runAdd(["button", "card"], emptyDir)
expect(updateAppIndex).not.toHaveBeenCalled()
})
})
})
it("does not update the app index for a non-matching single component", async () => {
await withTempDir(async (emptyDir) => {
await withTempDir(async (projectPath) => {
mockPrompts({
base: "radix",
selectedPreset: "nova",
})
vi.mocked(createProject).mockResolvedValue({
projectPath,
projectName: "app",
template: "next",
} as any)
vi.mocked(runInit).mockResolvedValue(baseConfig(projectPath))
await runAdd(["button"], emptyDir)
expect(updateAppIndex).not.toHaveBeenCalled()
})
})
})
})
describe("registry:style / registry:theme overwrite confirm", () => {
it("exits 1 when the user declines the overwrite confirm", async () => {
await withTempDir(async (cwd) => {
mockPrompts({ confirm: false })
await expect(runAdd(["style-item"], cwd)).rejects.toThrow(
"process.exit:1"
)
expect(addComponents).not.toHaveBeenCalled()
})
})
it("proceeds when the user confirms the overwrite", async () => {
await withFixtureCopy("config-full", async (cwd) => {
mockPrompts({ confirm: true })
await runAdd(["style-item"], cwd)
expect(addComponents).toHaveBeenCalledTimes(1)
expect(addComponents).toHaveBeenCalledWith(
["style-item"],
expect.any(Object),
expect.any(Object)
)
})
})
it("skips the confirm prompt entirely with --yes", async () => {
await withFixtureCopy("config-full", async (cwd) => {
await runAdd(["style-item", "--yes"], cwd)
expect(prompts).not.toHaveBeenCalled()
expect(addComponents).toHaveBeenCalledTimes(1)
})
})
it("also prompts for registry:theme items", async () => {
await withTempDir(async (cwd) => {
mockPrompts({ confirm: false })
await expect(runAdd(["theme-item"], cwd)).rejects.toThrow(
"process.exit:1"
)
expect(addComponents).not.toHaveBeenCalled()
})
})
})
describe("universal registry item short-circuit", () => {
it("calls addComponents directly without running preflight/init", async () => {
await withTempDir(async (cwd) => {
await runAdd(["universal-item"], cwd)
expect(addComponents).toHaveBeenCalledTimes(1)
expect(addComponents).toHaveBeenCalledWith(
["universal-item"],
expect.any(Object),
expect.any(Object)
)
expect(runInit).not.toHaveBeenCalled()
expect(prompts).not.toHaveBeenCalled()
})
})
})
describe("deprecated components guard (Tailwind v4)", () => {
it("exits 1 and warns when adding toaster", async () => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
const { logger } = await import("@/src/utils/logger")
await expect(runAdd(["toaster"], cwd)).rejects.toThrow("process.exit:1")
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("toaster component is deprecated")
)
expect(addComponents).not.toHaveBeenCalled()
})
})
it("exits 1 and warns when adding toast to a legacy style", async () => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
const { logger } = await import("@/src/utils/logger")
await expect(runAdd(["toast"], cwd)).rejects.toThrow("process.exit:1")
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("only available for Base UI projects")
)
expect(addComponents).not.toHaveBeenCalled()
})
})
it.each(["radix-nova", "aria-nova"])(
"exits 1 and warns when adding toast to %s",
async (style) => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
const { logger } = await import("@/src/utils/logger")
await setConfigStyle(cwd, style)
await expect(runAdd(["toast"], cwd)).rejects.toThrow("process.exit:1")
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("only available for Base UI projects")
)
expect(addComponents).not.toHaveBeenCalled()
})
}
)
it("allows adding toast to a Base UI style", async () => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
await setConfigStyle(cwd, "base-nova")
await runAdd(["toast"], cwd)
expect(addComponents).toHaveBeenCalledWith(
["toast"],
expect.objectContaining({ style: "base-nova" }),
expect.any(Object)
)
})
})
it("allows explicitly adding sonner to a Base UI style", async () => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
await setConfigStyle(cwd, "base-nova")
await runAdd(["sonner"], cwd)
expect(addComponents).toHaveBeenCalledWith(
["sonner"],
expect.objectContaining({ style: "base-nova" }),
expect.any(Object)
)
})
})
it("filters toast from --all for legacy styles", async () => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
await runAdd(["--all"], cwd)
expect(addComponents).toHaveBeenCalledWith(
["button", "sonner"],
expect.any(Object),
expect.any(Object)
)
})
})
it("includes toast in --all for Base UI styles", async () => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
await setConfigStyle(cwd, "base-nova")
await runAdd(["--all"], cwd)
expect(addComponents).toHaveBeenCalledWith(
["button", "toast"],
expect.objectContaining({ style: "base-nova" }),
expect.any(Object)
)
})
})
it.each([
{ style: "new-york-v4", includesToast: false },
{ style: "base-nova", includesToast: true },
])(
"filters the interactive picker for $style",
async ({ style, includesToast }) => {
await withFixtureCopy("vite-with-tailwind", async (cwd) => {
await setConfigStyle(cwd, style)
mockPrompts({ components: ["button"] })
await runAdd([], cwd)
const componentPrompt = vi
.mocked(prompts)
.mock.calls.map(([options]) => options)
.find(
(options) =>
!Array.isArray(options) && options.name === "components"
) as
| { choices?: Array<{ value: string }>; name?: string }
| undefined
const choices = (componentPrompt?.choices ?? []).map(
(choice) => choice.value
)
expect(componentPrompt).toBeDefined()
expect(choices.includes("toast")).toBe(includesToast)
expect(choices.includes("sonner")).toBe(!includesToast)
expect(choices).not.toContain("toaster")
})
}
)
})
})

View File

@@ -0,0 +1,430 @@
import path from "path"
import { runInit } from "@/src/commands/init"
import { preFlightAdd } from "@/src/preflights/preflight-add"
import { parsePresetStyle, type PresetBase } from "@/src/preset/preset"
import {
promptForBase,
promptForPreset,
resolveRegistryBaseConfig,
} from "@/src/preset/presets"
import { getRegistryItems, getShadcnRegistryIndex } from "@/src/registry/api"
import {
COMPONENTS_HIDDEN_FROM_SELECTION,
DEPRECATED_COMPONENTS,
} from "@/src/registry/constants"
import { clearRegistryContext } from "@/src/registry/context"
import { registryItemTypeSchema } from "@/src/registry/schema"
import { isUniversalRegistryItem } from "@/src/registry/utils"
import { getTemplateForFramework } from "@/src/templates/index"
import { addComponents } from "@/src/utils/add-components"
import { createProject } from "@/src/utils/create-project"
import { dryRunComponents } from "@/src/utils/dry-run"
import { formatDryRunResult } from "@/src/utils/dry-run-formatter"
import { loadEnvFiles } from "@/src/utils/env-loader"
import * as ERRORS from "@/src/utils/errors"
import { createConfig, getConfig } from "@/src/utils/get-config"
import { getProjectInfo } from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { ensureRegistriesInConfig } from "@/src/utils/registries"
import { spinner } from "@/src/utils/spinner"
import { updateAppIndex } from "@/src/utils/update-app-index"
import { Command } from "commander"
import prompts from "prompts"
import { z } from "zod"
export const addOptionsSchema = z.object({
components: z.array(z.string()).optional(),
yes: z.boolean(),
overwrite: z.boolean(),
cwd: z.string(),
all: z.boolean(),
path: z.string().optional(),
silent: z.boolean(),
dryRun: z.boolean(),
diff: z.union([z.string(), z.literal(true)]).optional(),
view: z.union([z.string(), z.literal(true)]).optional(),
})
export const add = new Command()
.name("add")
.description("add a component to your project")
.argument("[components...]", "item addresses to add")
.option("-y, --yes", "skip confirmation prompt.", false)
.option("-o, --overwrite", "overwrite existing files.", false)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("-a, --all", "add all available components", false)
.option("-p, --path <path>", "the path to add the component to.")
.option("-s, --silent", "mute output.", false)
.option("--dry-run", "preview changes without writing files.", false)
.option("--diff [path]", "show diff for a file.")
.option("--view [path]", "show file contents.")
.action(async (components, opts) => {
try {
const options = addOptionsSchema.parse({
components,
...opts,
cwd: path.resolve(opts.cwd),
})
await loadEnvFiles(options.cwd)
const isDryRun = options.dryRun || options.diff || options.view
let initialConfig = await getConfig(options.cwd)
const hasExistingConfig = !!initialConfig
if (!initialConfig) {
initialConfig = createConfig({
style: "new-york",
resolvedPaths: {
cwd: options.cwd,
},
})
}
let hasNewRegistries = false
if (components.length > 0) {
const { config: updatedConfig, newRegistries } =
await ensureRegistriesInConfig(components, initialConfig, {
silent: options.silent,
writeFile: false,
})
initialConfig = updatedConfig
hasNewRegistries = newRegistries.length > 0
}
const projectInfo = await getProjectInfo(options.cwd)
const { base: configuredBase } = parsePresetStyle(initialConfig.style)
if (hasExistingConfig && projectInfo?.tailwindVersion === "v4") {
warnForDeprecatedComponents(components, configuredBase)
}
let itemType: z.infer<typeof registryItemTypeSchema> | undefined
let shouldInstallStyleIndex = true
const shouldResolveInitialItem =
components.length > 0 &&
(hasExistingConfig ||
!DEPRECATED_COMPONENTS.some(
(component) => component.name === components[0]
))
if (shouldResolveInitialItem) {
const [registryItem] = await getRegistryItems([components[0]], {
config: initialConfig,
})
itemType = registryItem?.type
shouldInstallStyleIndex =
itemType !== "registry:theme" &&
itemType !== "registry:style" &&
itemType !== "registry:base"
if (isUniversalRegistryItem(registryItem) && !isDryRun) {
await addComponents(components, initialConfig, options)
return
}
if (
!options.yes &&
!isDryRun &&
(itemType === "registry:style" || itemType === "registry:theme")
) {
logger.break()
const { confirm } = await prompts({
type: "confirm",
name: "confirm",
message: highlighter.warn(
`You are about to install a new ${itemType.replace(
"registry:",
""
)}. \nExisting CSS variables and components will be overwritten. Continue?`
),
})
if (!confirm) {
logger.break()
logger.log(`Installation cancelled.`)
logger.break()
process.exit(1)
}
}
}
if (!options.components?.length) {
options.components = await promptForRegistryComponents(
options,
initialConfig.style
)
}
if (!components.length && projectInfo?.tailwindVersion === "v4") {
warnForDeprecatedComponents(options.components ?? [], configuredBase)
}
let { errors, config } = await preFlightAdd(options)
// No components.json file. Prompt the user to run init.
let initHasRun = false
if (errors[ERRORS.MISSING_CONFIG]) {
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: `You need to create a ${highlighter.info(
"components.json"
)} file to add components. Proceed?`,
initial: true,
})
if (!proceed) {
logger.break()
process.exit(1)
}
// Infer template from project framework.
const inferredTemplate = getTemplateForFramework(
projectInfo?.framework.name
)
// Prompt for base and preset.
const base = await promptForBase()
warnForDeprecatedComponents(options.components ?? [], base)
const { url: initUrl } = await promptForPreset({
rtl: false,
base,
template: inferredTemplate,
})
// Resolve registry:base config.
const {
registryBaseConfig,
installStyleIndex,
url: cleanInitUrl,
} = await resolveRegistryBaseConfig(initUrl, options.cwd)
config = await runInit({
cwd: options.cwd,
yes: true,
force: true,
defaults: false,
skipPreflight: false,
silent: options.silent && !hasNewRegistries,
isNewProject: false,
cssVariables: true,
rtl: false,
installStyleIndex,
components: [cleanInitUrl, ...(options.components ?? [])],
registryBaseConfig,
})
initHasRun = true
}
let shouldUpdateAppIndex = false
if (errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
const selectedBase = await promptForBase()
warnForDeprecatedComponents(options.components ?? [], selectedBase)
const { projectPath, template } = await createProject({
cwd: options.cwd,
force: options.overwrite,
components: options.components,
})
if (!projectPath) {
logger.break()
process.exit(1)
}
options.cwd = projectPath
const { url: initUrl } = await promptForPreset({
rtl: false,
base: selectedBase,
template,
})
const {
registryBaseConfig,
installStyleIndex,
url: cleanInitUrl,
} = await resolveRegistryBaseConfig(initUrl, options.cwd)
config = await runInit({
cwd: options.cwd,
yes: true,
force: true,
defaults: false,
skipPreflight: true,
silent: !hasNewRegistries && options.silent,
isNewProject: true,
cssVariables: true,
rtl: false,
installStyleIndex,
components: [cleanInitUrl, ...(options.components ?? [])],
registryBaseConfig,
})
initHasRun = true
shouldUpdateAppIndex =
options.components?.length === 1 &&
!!options.components[0].match(/\/chat\/b\//)
}
if (!config) {
throw new Error(
`Failed to read config at ${highlighter.info(options.cwd)}.`
)
}
const { config: updatedConfig } = await ensureRegistriesInConfig(
options.components,
config,
{
silent: options.silent || hasNewRegistries,
writeFile: !isDryRun,
}
)
config = updatedConfig
// Dry-run mode: preview changes without writing files.
// --diff and --view imply --dry-run.
if (isDryRun) {
const dryRunSpinner = spinner("Resolving items.", {
silent: options.silent,
}).start()
const dryRunResult = await dryRunComponents(
options.components,
config,
{
overwrite: options.overwrite,
}
)
dryRunSpinner.stop()
logger.log(
formatDryRunResult(dryRunResult, options.components, {
diff: options.diff,
view: options.view,
})
)
return
}
if (!initHasRun) {
await addComponents(options.components, config, options)
}
// If we're adding a single component and it's from the v0 registry,
// let's update the app/page.tsx file to import the component.
if (shouldUpdateAppIndex) {
await updateAppIndex(options.components[0], config)
}
} catch (error) {
logger.break()
handleError(error)
} finally {
clearRegistryContext()
}
})
async function promptForRegistryComponents(
options: z.infer<typeof addOptionsSchema>,
style: string
) {
const registryIndex = await getShadcnRegistryIndex()
if (!registryIndex) {
logger.break()
handleError(new Error("Failed to fetch registry index."))
return []
}
const { base } = parsePresetStyle(style)
if (options.all) {
return registryIndex
.map((entry) => entry.name)
.filter((component) => isComponentSelectable(component, base))
}
if (options.components?.length) {
return options.components
}
const { components } = await prompts({
type: "multiselect",
name: "components",
message: "Which components would you like to add?",
hint: "Space to select. A to toggle all. Enter to submit.",
instructions: false,
choices: registryIndex
.filter(
(entry) =>
entry.type === "registry:ui" &&
isComponentSelectable(entry.name, base)
)
.map((entry) => ({
title: entry.name,
value: entry.name,
selected: options.all ? true : options.components?.includes(entry.name),
})),
})
if (!components?.length) {
logger.warn("No components selected. Exiting.")
logger.info("")
process.exit(1)
}
const result = z.array(z.string()).safeParse(components)
if (!result.success) {
logger.error("")
handleError(new Error("Something went wrong. Please try again."))
return []
}
return result.data
}
function warnForDeprecatedComponents(
components: string[],
base: PresetBase | undefined
) {
const deprecatedComponents = DEPRECATED_COMPONENTS.filter(
(component) =>
components.includes(component.name) &&
isDeprecatedComponent(component.name, base)
)
if (!deprecatedComponents.length) {
return
}
logger.break()
deprecatedComponents.forEach((component) => {
logger.warn(highlighter.warn(component.message))
})
logger.break()
process.exit(1)
}
function isDeprecatedComponent(name: string, base: PresetBase | undefined) {
const component = DEPRECATED_COMPONENTS.find(
(component) => component.name === name
)
return (
!!component &&
(!component.availableIn || !base || !component.availableIn.includes(base))
)
}
function isComponentSelectable(name: string, base: PresetBase | undefined) {
if (isDeprecatedComponent(name, base)) {
return false
}
const component = COMPONENTS_HIDDEN_FROM_SELECTION.find(
(component) => component.name === name
)
return !component || !base || !component.hiddenIn.includes(base)
}

View File

@@ -0,0 +1,186 @@
import { REGISTRY_URL } from "@/src/registry/constants"
import { describe, expect, it } from "vitest"
import {
getPresetUrlOnly,
parseApplyOnlyParts,
resolveApplyInitUrl,
resolveApplyOnly,
validateApplyOnlyPreset,
} from "./apply"
const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "")
describe("resolveApplyInitUrl", () => {
it("should keep aria as the current base", () => {
const initUrl = resolveApplyInitUrl("b0", "aria")
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("base")).toBe("aria")
})
it("should include the inferred template for preset codes", () => {
const initUrl = resolveApplyInitUrl("a0", "base", {
template: "next",
rtl: true,
})
const parsed = new URL(initUrl)
expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/init`)
expect(parsed.searchParams.get("template")).toBe("next")
expect(parsed.searchParams.get("preset")).toBe("a0")
expect(parsed.searchParams.get("base")).toBe("base")
expect(parsed.searchParams.get("rtl")).toBe("true")
})
it("should include the inferred template for named presets", () => {
const initUrl = resolveApplyInitUrl("lyra", "base", {
template: "next",
rtl: true,
})
const parsed = new URL(initUrl)
expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/init`)
expect(parsed.searchParams.get("template")).toBe("next")
expect(parsed.searchParams.get("base")).toBe("base")
expect(parsed.searchParams.get("rtl")).toBe("true")
})
it("should keep the current base for raw preset URLs without injecting a template", () => {
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default`
const initUrl = resolveApplyInitUrl(presetUrl, "base", {
template: "next",
rtl: true,
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("template")).toBeNull()
expect(parsed.searchParams.get("track")).toBe("1")
expect(parsed.searchParams.get("base")).toBe("base")
expect(parsed.searchParams.get("rtl")).toBe("true")
})
it("should include only for preset codes", () => {
const initUrl = resolveApplyInitUrl("a0", "base", {
template: "next",
rtl: true,
only: "theme,font",
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("only")).toBe("theme,font")
})
it("should include only for named presets", () => {
const initUrl = resolveApplyInitUrl("lyra", "base", {
template: "next",
rtl: true,
only: "font",
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("only")).toBe("font")
})
it("should include only for raw preset URLs", () => {
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default`
const initUrl = resolveApplyInitUrl(presetUrl, "base", {
template: "next",
rtl: true,
only: "theme",
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("only")).toBe("theme")
})
it("should preserve only from raw preset URLs", () => {
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&baseColor=neutral&theme=neutral&iconLibrary=lucide&font=inter&rtl=false&menuAccent=subtle&menuColor=default&radius=default&only=font`
const initUrl = resolveApplyInitUrl(presetUrl, "base", {
template: "next",
rtl: true,
})
const parsed = new URL(initUrl)
expect(parsed.searchParams.get("only")).toBe("font")
})
})
describe("parseApplyOnlyParts", () => {
it("returns undefined when only is omitted", () => {
expect(resolveApplyOnly(undefined)).toBeUndefined()
})
it("rejects missing only values with allowed values", () => {
expect(() => resolveApplyOnly(true)).toThrow(
[
"Missing value for --only.",
"Use one or more of: theme, font.",
"Example: shadcn apply <preset> --only theme,font.",
].join("\n")
)
})
it("normalizes explicit values and aliases", () => {
expect(resolveApplyOnly("font")).toEqual(["font"])
expect(parseApplyOnlyParts("theme,font")).toEqual(["theme", "font"])
})
it("dedupes and accepts plural aliases", () => {
expect(parseApplyOnlyParts("theme,fonts,font")).toEqual(["theme", "font"])
})
it("rejects invalid values", () => {
expect(() => parseApplyOnlyParts("theme,colors")).toThrow(
[
"Invalid value for --only: theme,colors.",
"Use one or more of: theme, font.",
"Example: shadcn apply <preset> --only theme,font.",
].join("\n")
)
expect(() => parseApplyOnlyParts("")).toThrow("Invalid value for --only")
expect(() => parseApplyOnlyParts("icon")).toThrow(
"Use one or more of: theme, font."
)
})
})
describe("getPresetUrlOnly", () => {
it("reads only from shadcn init URLs", () => {
const presetUrl = `${SHADCN_URL}/init?base=radix&style=nova&only=font`
expect(getPresetUrlOnly(presetUrl)).toBe("font")
})
it("reads only from non-shadcn init URLs", () => {
const presetUrl =
"http://localhost:4000/init?base=radix&style=nova&only=font"
expect(getPresetUrlOnly(presetUrl)).toBe("font")
expect(resolveApplyOnly(getPresetUrlOnly(presetUrl))).toEqual(["font"])
})
it("ignores only on non-init URLs", () => {
const presetUrl =
"http://localhost:4000/r/styles/nova/button.json?only=font"
expect(getPresetUrlOnly(presetUrl)).toBeUndefined()
})
})
describe("validateApplyOnlyPreset", () => {
it("rejects only without a preset", () => {
expect(() => validateApplyOnlyPreset({ only: ["theme"] })).toThrow(
[
"Missing preset for --only.",
"Use: shadcn apply <preset> --only theme,font.",
].join("\n")
)
})
it("allows only with a preset", () => {
expect(() =>
validateApplyOnlyPreset({ preset: "a0", only: ["theme"] })
).not.toThrow()
})
})

View File

@@ -0,0 +1,601 @@
import path from "path"
import { runInit } from "@/src/commands/init"
import { preFlightApply } from "@/src/preflights/preflight-apply"
import {
decodePreset,
isPresetCode,
type PresetBase,
} from "@/src/preset/preset"
import {
DEFAULT_PRESETS,
promptToOpenPresetBuilder,
resolveCreateUrl,
resolveInitUrl,
resolveRegistryBaseConfig,
} from "@/src/preset/presets"
import { SHADCN_URL } from "@/src/registry/constants"
import { clearRegistryContext } from "@/src/registry/context"
import { registryConfigSchema } from "@/src/registry/schema"
import { isUrl } from "@/src/registry/utils"
import { getTemplateForFramework } from "@/src/templates/index"
import { loadEnvFiles } from "@/src/utils/env-loader"
import * as ERRORS from "@/src/utils/errors"
import {
createFileBackup,
deleteFileBackup,
FileBackupError,
restoreFileBackup,
withFileBackup,
} from "@/src/utils/file-helper"
import {
getBase,
getWorkspaceConfig,
type Config,
} from "@/src/utils/get-config"
import {
getProjectComponents,
getProjectInfo,
} from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { Command } from "commander"
import fs from "fs-extra"
import prompts from "prompts"
import { z } from "zod"
export const applyOptionsSchema = z.object({
cwd: z.string(),
positionalPreset: z.string().optional(),
preset: z.string().optional(),
only: z.union([z.boolean(), z.string()]).optional(),
yes: z.boolean(),
silent: z.boolean(),
})
const APPLY_ONLY_VALUES = ["theme", "font"] as const
type ApplyOnlyValue = (typeof APPLY_ONLY_VALUES)[number]
class ApplyOnlyError extends Error {
constructor(message: string) {
super(message)
this.name = "ApplyOnlyError"
}
}
class ApplyWorkspaceSyncError extends Error {
constructor(message: string) {
super(message)
this.name = "ApplyWorkspaceSyncError"
}
}
export const apply = new Command()
.name("apply")
.description("apply a preset to an existing project")
.argument("[preset]", "the preset to apply")
.option("--preset <preset>", "preset configuration to apply")
.option("--only [parts]", "apply only parts of a preset: theme, font")
.option("-y, --yes", "skip confirmation prompt.", false)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("-s, --silent", "mute output.", false)
.action(async (positionalPreset, opts) => {
try {
const options = applyOptionsSchema.parse({
...opts,
cwd: path.resolve(opts.cwd),
positionalPreset,
})
const preset = resolveApplyPreset(options)
const explicitOnly = resolveApplyOnly(options.only)
validateApplyOnlyPreset({ preset, only: explicitOnly })
const preflight = await preFlightApply(options)
if (preflight.errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
logger.break()
logger.error(
`The ${highlighter.info(
"apply"
)} command only works in an existing project.`
)
logger.error(`Run ${highlighter.info(getInitCommand(preset))} first.`)
logger.break()
process.exit(1)
}
if (preflight.errors[ERRORS.MISSING_CONFIG]) {
logger.break()
logger.error(
`No ${highlighter.info("components.json")} found at ${highlighter.info(
options.cwd
)}.`
)
logger.error(`Run ${highlighter.info(getInitCommand(preset))} first.`)
logger.break()
process.exit(1)
}
const existingConfig = preflight.config
if (!existingConfig) {
process.exit(1)
}
const rtl = existingConfig.rtl ?? false
const template = await resolveApplyTemplate(options.cwd)
if (!preset) {
const createUrl = resolveCreateUrl({
command: "init",
template,
base: getBase(existingConfig.style),
rtl,
})
await promptToOpenPresetBuilder({
createUrl,
followUp: `Then run ${highlighter.info(
"shadcn apply --preset <preset>"
)} with the preset code or preset URL from ui.shadcn.com.`,
prompt: !options.yes,
})
process.exit(0)
}
validatePreset(preset)
const only = explicitOnly ?? resolveApplyOnly(getPresetUrlOnly(preset))
const shouldReinstallComponents = !only
const reinstallComponents = shouldReinstallComponents
? await getProjectComponents(options.cwd)
: []
if (!options.yes) {
logger.break()
if (!only) {
logger.warn(
highlighter.warn(
`Applying a new preset will overwrite existing UI components, fonts, and CSS variables.`
)
)
} else {
logger.warn(
highlighter.warn(
`Applying the selected preset parts will update your project configuration and styles.`
)
)
}
logger.warn(
`Commit or stash your changes before continuing so you can easily go back.`
)
if (shouldReinstallComponents) {
logger.break()
logger.log(" The following components will be re-installed:")
if (reinstallComponents.length) {
for (let i = 0; i < reinstallComponents.length; i += 8) {
logger.log(
` - ${reinstallComponents.slice(i, i + 8).join(", ")}`
)
}
} else {
logger.log(" - No installed UI components were detected.")
}
}
logger.break()
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Would you like to continue?",
initial: false,
})
if (!proceed) {
logger.break()
process.exit(1)
}
}
await loadEnvFiles(options.cwd)
const currentBase = getBase(existingConfig.style)
const initUrl = resolveApplyInitUrl(preset, currentBase, {
template,
rtl,
only: only?.join(","),
})
const config = await withFileBackup(
path.resolve(options.cwd, "components.json"),
async () => {
const {
registryBaseConfig,
installStyleIndex,
url: cleanUrl,
} = await resolveRegistryBaseConfig(initUrl, options.cwd, {
registries: existingConfig.registries as
| z.infer<typeof registryConfigSchema>
| undefined,
})
const applyRegistryBaseConfig = resolveApplyRegistryBaseConfig({
registryBaseConfig,
existingConfig,
only,
})
return await runInit({
cwd: options.cwd,
yes: true,
force: false,
reinstall: shouldReinstallComponents,
defaults: false,
silent: options.silent,
isNewProject: false,
cssVariables: true,
installStyleIndex,
registryBaseConfig: applyRegistryBaseConfig,
existingConfig,
components: [cleanUrl, ...reinstallComponents],
})
}
)
await syncApplyWorkspaceConfigs(config, { only })
logger.break()
logger.log("Preset applied successfully.")
logger.break()
} catch (error) {
if (error instanceof ApplyOnlyError) {
for (const line of error.message.split("\n")) {
logger.error(line)
}
logger.break()
process.exit(1)
}
if (error instanceof FileBackupError) {
logger.error(
`Could not back up ${highlighter.info("components.json")}. Aborting.`
)
logger.break()
process.exit(1)
}
if (error instanceof ApplyWorkspaceSyncError) {
logger.error(error.message)
logger.break()
process.exit(1)
}
logger.break()
handleError(error)
} finally {
clearRegistryContext()
}
})
function resolveApplyPreset(options: z.infer<typeof applyOptionsSchema>) {
const positionalPreset = options.positionalPreset?.trim()
const flagPreset = options.preset?.trim()
if (positionalPreset && flagPreset && positionalPreset !== flagPreset) {
logger.error(
`Received two different preset values. Use either the positional preset or ${highlighter.info(
"--preset"
)}, or pass the same value to both.`
)
logger.break()
process.exit(1)
}
return flagPreset ?? positionalPreset
}
export function getPresetUrlOnly(preset: string) {
if (!isUrl(preset)) {
return undefined
}
const url = new URL(preset)
if (url.pathname !== "/init") {
return undefined
}
return url.searchParams.get("only") ?? undefined
}
export function resolveApplyOnly(
value: z.infer<typeof applyOptionsSchema>["only"]
) {
if (value === undefined || value === false) {
return undefined
}
if (value === true) {
throw new ApplyOnlyError(
[
"Missing value for --only.",
`Use one or more of: ${APPLY_ONLY_VALUES.join(", ")}.`,
"Example: shadcn apply <preset> --only theme,font.",
].join("\n")
)
}
return parseApplyOnlyParts(value)
}
export function parseApplyOnlyParts(value: string) {
const aliases: Record<string, ApplyOnlyValue> = {
theme: "theme",
font: "font",
fonts: "font",
}
const parts = value
.split(",")
.map((part) => part.trim().toLowerCase())
.filter(Boolean)
const invalid = parts.filter((part) => !aliases[part])
if (!parts.length || invalid.length) {
throw new ApplyOnlyError(
[
`Invalid value for --only: ${value}.`,
`Use one or more of: ${APPLY_ONLY_VALUES.join(", ")}.`,
"Example: shadcn apply <preset> --only theme,font.",
].join("\n")
)
}
return Array.from(new Set(parts.map((part) => aliases[part])))
}
export function validateApplyOnlyPreset(options: {
preset?: string
only?: ApplyOnlyValue[]
}) {
if (!options.only || options.preset) {
return
}
throw new ApplyOnlyError(
[
"Missing preset for --only.",
"Use: shadcn apply <preset> --only theme,font.",
].join("\n")
)
}
function resolveApplyRegistryBaseConfig(options: {
registryBaseConfig: Record<string, unknown> | undefined
existingConfig: Record<string, unknown>
only: ApplyOnlyValue[] | undefined
}) {
if (!options.only || options.only.includes("theme")) {
return options.registryBaseConfig
}
const existingTailwind =
typeof options.existingConfig.tailwind === "object" &&
options.existingConfig.tailwind !== null
? options.existingConfig.tailwind
: {}
const registryTailwind =
typeof options.registryBaseConfig?.tailwind === "object" &&
options.registryBaseConfig.tailwind !== null
? options.registryBaseConfig.tailwind
: {}
const config: Record<string, unknown> = {
...options.registryBaseConfig,
tailwind: {
...existingTailwind,
...registryTailwind,
},
}
if (options.existingConfig.menuColor) {
config.menuColor = options.existingConfig.menuColor
}
if (options.existingConfig.menuAccent) {
config.menuAccent = options.existingConfig.menuAccent
}
return config
}
function validatePreset(preset: string) {
if (isUrl(preset) || isPresetCode(preset)) {
return
}
const knownPresetNames = Object.keys(DEFAULT_PRESETS)
if (!knownPresetNames.includes(preset)) {
logger.error(
`Invalid preset: ${highlighter.info(
preset
)}.\nUse one of the available presets: ${knownPresetNames.join(", ")} \nor build your own at ${highlighter.info(`${SHADCN_URL}/create`)}`
)
logger.break()
process.exit(1)
}
}
async function resolveApplyTemplate(cwd: string) {
const projectInfo = await getProjectInfo(cwd)
return getTemplateForFramework(projectInfo?.framework.name)
}
async function syncApplyWorkspaceConfigs(
config: Config,
options?: {
only?: string[]
}
) {
if (options?.only && !options.only.includes("theme")) {
return
}
const linkedConfigs = await getApplyWorkspaceConfigs(config)
if (!linkedConfigs.length) {
return
}
const patch = {
style: config.style,
tailwind: {
baseColor: config.tailwind.baseColor,
cssVariables: config.tailwind.cssVariables,
},
...(config.iconLibrary ? { iconLibrary: config.iconLibrary } : {}),
...(config.rtl !== undefined ? { rtl: config.rtl } : {}),
...(config.menuColor ? { menuColor: config.menuColor } : {}),
...(config.menuAccent ? { menuAccent: config.menuAccent } : {}),
}
const workspaceConfigs = []
for (const linkedConfig of linkedConfigs) {
const configPath = path.resolve(
linkedConfig.resolvedPaths.cwd,
"components.json"
)
if (!(await fs.pathExists(configPath))) {
continue
}
workspaceConfigs.push({
configPath,
existingConfig: await fs.readJson(configPath),
})
}
try {
for (const workspaceConfig of workspaceConfigs) {
const backupPath = createFileBackup(workspaceConfig.configPath)
if (!backupPath) {
throw new FileBackupError(workspaceConfig.configPath)
}
}
for (const workspaceConfig of workspaceConfigs) {
await fs.writeJson(
workspaceConfig.configPath,
{
...workspaceConfig.existingConfig,
...patch,
tailwind: {
...workspaceConfig.existingConfig.tailwind,
...patch.tailwind,
},
},
{ spaces: 2 }
)
}
for (const workspaceConfig of workspaceConfigs) {
deleteFileBackup(workspaceConfig.configPath)
}
} catch (error) {
for (const workspaceConfig of [...workspaceConfigs].reverse()) {
restoreFileBackup(workspaceConfig.configPath)
}
throw new ApplyWorkspaceSyncError(
`Failed to sync linked workspace configs.${error instanceof Error ? ` ${error.message}` : ""}`
)
}
}
async function getApplyWorkspaceConfigs(config: Config) {
const workspaceConfig = await getWorkspaceConfig(config)
if (!workspaceConfig) {
return []
}
const linkedConfigs = new Map<string, Config>()
for (const linkedConfig of Object.values(workspaceConfig)) {
if (linkedConfig.resolvedPaths.cwd === config.resolvedPaths.cwd) {
continue
}
linkedConfigs.set(linkedConfig.resolvedPaths.cwd, linkedConfig)
}
return Array.from(linkedConfigs.values()).sort((a, b) =>
a.resolvedPaths.cwd.localeCompare(b.resolvedPaths.cwd)
)
}
export function resolveApplyInitUrl(
preset: string,
currentBase: PresetBase,
options: { template?: string; rtl?: boolean; only?: string } = {}
) {
if (isUrl(preset)) {
const url = new URL(preset)
if (url.pathname === "/init" && preset.startsWith(SHADCN_URL)) {
url.searchParams.set("track", "1")
}
url.searchParams.set("base", currentBase)
url.searchParams.set("rtl", String(options.rtl ?? false))
if (options.only) {
url.searchParams.set("only", options.only)
}
return url.toString()
}
if (isPresetCode(preset)) {
const decoded = decodePreset(preset)
if (!decoded) {
logger.error(`Invalid preset code: ${highlighter.info(preset)}`)
logger.break()
process.exit(1)
}
return resolveInitUrl(
{
...decoded,
base: currentBase,
rtl: options.rtl ?? false,
},
{ preset, template: options.template, only: options.only }
)
}
const resolvedPreset = DEFAULT_PRESETS[preset as keyof typeof DEFAULT_PRESETS]
return resolveInitUrl(
{
...resolvedPreset,
base: currentBase,
rtl: options.rtl ?? resolvedPreset.rtl,
},
{ template: options.template, only: options.only }
)
}
function quoteShellArg(value: string) {
return /[^A-Za-z0-9_./:-]/.test(value) ? JSON.stringify(value) : value
}
function getInitCommand(preset?: string) {
if (!preset) {
return "shadcn init"
}
return `shadcn init --preset ${quoteShellArg(preset)}`
}

View File

@@ -0,0 +1,142 @@
import * as fs from "fs/promises"
import { tmpdir } from "os"
import * as path from "path"
import { describe, expect, it, vi } from "vitest"
import { build } from "./build"
vi.mock("@/src/utils/handle-error", () => ({
handleError: vi.fn((error) => {
throw error
}),
}))
vi.mock("@/src/utils/spinner", () => ({
spinner: () => ({
start: vi.fn(),
succeed: vi.fn(),
}),
}))
describe("build command", () => {
it("writes flattened registries for source registries that use include", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
include: ["components/ui/registry.json"],
}),
"components/ui/registry.json": JSON.stringify({
items: [
{
name: "button",
type: "registry:ui",
files: [
{
path: "button.tsx",
type: "registry:ui",
},
],
},
],
}),
"components/ui/button.tsx": "export function Button() {}",
})
await build.parseAsync(
["node", "shadcn", "registry.json", "--cwd", cwd, "--output", "public/r"],
{ from: "node" }
)
const outputDir = path.join(cwd, "public/r")
const registry = JSON.parse(
await fs.readFile(path.join(outputDir, "registry.json"), "utf-8")
)
const button = JSON.parse(
await fs.readFile(path.join(outputDir, "button.json"), "utf-8")
)
expect(registry).toMatchObject({
name: "example",
homepage: "https://example.com",
items: [
{
name: "button",
files: [
{
path: "components/ui/button.tsx",
},
],
},
],
})
expect(registry).not.toHaveProperty("include")
expect(registry.items[0].files[0]).not.toHaveProperty("content")
expect(button).toMatchObject({
$schema: "https://ui.shadcn.com/schema/registry-item.json",
name: "button",
files: [
{
path: "components/ui/button.tsx",
content: "export function Button() {}",
},
],
})
})
it("creates nested output directories for item names with path segments", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
items: [
{
name: "extension/foo",
type: "registry:item",
files: [
{
path: "registry/extensions/foo.tsx",
type: "registry:file",
target: "extensions/foo.tsx",
},
],
},
],
}),
"registry/extensions/foo.tsx": "export function Foo() {}",
})
await build.parseAsync(
["node", "shadcn", "registry.json", "--cwd", cwd, "--output", "public/r"],
{ from: "node" }
)
const item = JSON.parse(
await fs.readFile(path.join(cwd, "public/r/extension/foo.json"), "utf-8")
)
expect(item).toMatchObject({
name: "extension/foo",
files: [
{
path: "registry/extensions/foo.tsx",
content: "export function Foo() {}",
},
],
})
})
})
async function createFixture(files: Record<string, string>) {
const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-build-"))
await Promise.all(
Object.entries(files).map(async ([filePath, content]) => {
const targetPath = path.join(cwd, filePath)
await fs.mkdir(path.dirname(targetPath), { recursive: true })
await fs.writeFile(targetPath, content)
})
)
return cwd
}

View File

@@ -0,0 +1,103 @@
import * as fs from "fs/promises"
import * as path from "path"
import { preFlightBuild } from "@/src/preflights/preflight-build"
import {
createRegistryCatalog,
createRegistryItem,
readRegistryWithIncludes,
} from "@/src/registry/loader"
import { handleError } from "@/src/utils/handle-error"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { Command } from "commander"
import { z } from "zod"
export const buildOptionsSchema = z.object({
cwd: z.string(),
registryFile: z.string(),
outputDir: z.string(),
})
export const build = new Command()
.name("build")
.description("build components for a shadcn registry")
.argument("[registry]", "path to registry.json file", "./registry.json")
.option(
"-o, --output <path>",
"destination directory for json files",
"./public/r"
)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.action(async (registryFile: string, opts) => {
try {
const options = buildOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
registryFile,
outputDir: opts.output,
})
const { resolvePaths } = await preFlightBuild(options)
const registryResult = await readRegistryWithIncludes(
resolvePaths.registryFile,
{
cwd: resolvePaths.cwd,
}
)
const resolvedRegistry = registryResult.registry
const registryRootDir = registryResult.usesInclude
? path.dirname(resolvePaths.registryFile)
: resolvePaths.cwd
const registryCatalog = createRegistryCatalog(
registryResult,
registryRootDir,
resolvePaths.cwd
)
const buildSpinner = spinner("Building registry...")
for (const registryItem of resolvedRegistry.items) {
buildSpinner.start(`Building ${registryItem.name}...`)
const registryItemForBuild = await createRegistryItem(
registryItem,
registryResult,
registryRootDir,
resolvePaths.cwd
)
// Write the registry item to the output directory.
// Item names can contain path segments (e.g. "extension/foo"), so
// ensure the nested output directory exists before writing.
const outputPath = path.resolve(
resolvePaths.outputDir,
`${registryItemForBuild.name}.json`
)
await fs.mkdir(path.dirname(outputPath), { recursive: true })
await fs.writeFile(
outputPath,
JSON.stringify(registryItemForBuild, null, 2)
)
}
if (registryResult.usesInclude) {
await fs.writeFile(
path.resolve(resolvePaths.outputDir, "registry.json"),
JSON.stringify(registryCatalog, null, 2)
)
} else {
// Copy registry.json to the output directory.
await fs.copyFile(
resolvePaths.registryFile,
path.resolve(resolvePaths.outputDir, "registry.json")
)
}
buildSpinner.succeed("Building registry.")
} catch (error) {
logger.break()
handleError(error)
}
})

View File

@@ -0,0 +1,255 @@
import { existsSync, promises as fs } from "fs"
import path from "path"
import {
fetchTree,
getItemTargetPath,
getRegistryBaseColor,
getShadcnRegistryIndex,
} from "@/src/registry/api"
import { registryIndexSchema } from "@/src/schema"
import { getSupportedFontMarkers } from "@/src/utils/font-markers"
import { Config, getConfig } from "@/src/utils/get-config"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { transform } from "@/src/utils/transformers"
import { transformCleanup } from "@/src/utils/transformers/transform-cleanup"
import { transformCssVars } from "@/src/utils/transformers/transform-css-vars"
import { transformFont } from "@/src/utils/transformers/transform-font"
import { transformIcons } from "@/src/utils/transformers/transform-icons"
import { transformImport } from "@/src/utils/transformers/transform-import"
import { transformMenu } from "@/src/utils/transformers/transform-menu"
import { transformRsc } from "@/src/utils/transformers/transform-rsc"
import { transformRtl } from "@/src/utils/transformers/transform-rtl"
import { transformTwPrefixes } from "@/src/utils/transformers/transform-tw-prefix"
import { Command } from "commander"
import { diffLines, type Change } from "diff"
import { z } from "zod"
const updateOptionsSchema = z.object({
component: z.string().optional(),
yes: z.boolean(),
cwd: z.string(),
path: z.string().optional(),
})
export const diff = new Command()
.name("diff")
.description("[DEPRECATED] Use `add [component] --diff` instead.")
.argument("[component]", "the component name")
.option("-y, --yes", "skip confirmation prompt.", false)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.action(async (name, opts) => {
try {
const options = updateOptionsSchema.parse({
component: name,
...opts,
})
const cwd = path.resolve(options.cwd)
if (!existsSync(cwd)) {
logger.error(`The path ${cwd} does not exist. Please try again.`)
process.exit(1)
}
const config = await getConfig(cwd)
if (!config) {
// Check if we're in a monorepo root.
if (await isMonorepoRoot(cwd)) {
const targets = await getMonorepoTargets(cwd)
if (targets.length > 0) {
formatMonorepoMessage("diff [component]", targets)
process.exit(1)
}
}
logger.warn(
`Configuration is missing. Please run ${highlighter.success(
`init`
)} to create a components.json file.`
)
process.exit(1)
}
const registryIndex = await getShadcnRegistryIndex()
if (!registryIndex) {
handleError(new Error("Failed to fetch registry index."))
process.exit(1)
}
if (!options.component) {
const targetDir = config.resolvedPaths.components
// Find all components that exist in the project.
const projectComponents = registryIndex.filter((item) => {
for (const file of item.files ?? []) {
const filePath = path.resolve(
targetDir,
typeof file === "string" ? file : file.path
)
if (existsSync(filePath)) {
return true
}
}
return false
})
// Check for updates.
const componentsWithUpdates = []
for (const component of projectComponents) {
const changes = await diffComponent(component, config)
if (changes.length) {
componentsWithUpdates.push({
name: component.name,
changes,
})
}
}
if (!componentsWithUpdates.length) {
logger.info("No updates found.")
process.exit(0)
}
logger.info("The following components have updates available:")
for (const component of componentsWithUpdates) {
logger.info(`- ${component.name}`)
for (const change of component.changes) {
logger.info(` - ${change.filePath}`)
}
}
logger.break()
logger.info(
`Run ${highlighter.success(`diff <component>`)} to see the changes.`
)
process.exit(0)
}
// Show diff for a single component.
const component = registryIndex.find(
(item) => item.name === options.component
)
if (!component) {
logger.error(
`The component ${highlighter.success(
options.component
)} does not exist.`
)
process.exit(1)
}
const changes = await diffComponent(component, config)
if (!changes.length) {
logger.info(`No updates found for ${options.component}.`)
process.exit(0)
}
for (const change of changes) {
logger.info(`- ${change.filePath}`)
await printDiff(change.patch)
logger.info("")
}
} catch (error) {
handleError(error)
}
})
async function diffComponent(
component: z.infer<typeof registryIndexSchema>[number],
config: Config
) {
const payload = await fetchTree(config.style, [component])
const baseColor = await getRegistryBaseColor(config.tailwind.baseColor)
const supportedFontMarkers = getSupportedFontMarkers(payload)
if (!payload) {
return []
}
const changes = []
for (const item of payload) {
const targetDir = await getItemTargetPath(config, item)
if (!targetDir) {
continue
}
for (const file of item.files ?? []) {
const filePath = path.resolve(
targetDir,
typeof file === "string" ? file : file.path
)
if (!existsSync(filePath)) {
continue
}
const fileContent = await fs.readFile(filePath, "utf8")
if (typeof file === "string" || !file.content) {
continue
}
const registryContent = await transform(
{
filename: file.path,
raw: file.content,
config,
baseColor,
supportedFontMarkers,
},
[
transformImport,
transformRsc,
transformCssVars,
transformTwPrefixes,
transformIcons,
transformMenu,
transformRtl,
transformFont,
transformCleanup,
]
)
const patch = diffLines(registryContent as string, fileContent)
if (patch.length > 1) {
changes.push({
filePath,
patch,
})
}
}
}
return changes
}
async function printDiff(diff: Change[]) {
diff.forEach((part) => {
if (part) {
if (part.added) {
return process.stdout.write(highlighter.success(part.value))
}
if (part.removed) {
return process.stdout.write(highlighter.error(part.value))
}
return process.stdout.write(part.value)
}
})
}

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest"
import { docs, resolveDocsBase } from "./docs"
describe("resolveDocsBase", () => {
it("accepts aria explicitly", () => {
expect(resolveDocsBase("aria", undefined)).toBe("aria")
expect(docs.helpInformation()).toContain("base, radix, or aria")
})
it("infers aria from the project style", () => {
expect(resolveDocsBase(undefined, "aria-nova")).toBe("aria")
})
it("rejects unsupported bases", () => {
expect(() => resolveDocsBase("unsupported", undefined)).toThrow(
"Expected one of: radix, base, aria"
)
})
})

View File

@@ -0,0 +1,127 @@
import path from "path"
import {
isPresetBase,
PRESET_BASES,
type PresetBase,
} from "@/src/preset/preset"
import { getShadcnRegistryIndex } from "@/src/registry/api"
import { SHADCN_URL } from "@/src/registry/constants"
import { getBase, getConfig } from "@/src/utils/get-config"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { Command } from "commander"
const SHADCN_BASE_URL = "https://ui.shadcn.com"
export const docs = new Command()
.name("docs")
.description("get docs, api references and usage examples for components")
.argument("<components...>", "component names")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option(
"-b, --base <base>",
"the base to use: base, radix, or aria. defaults to project base."
)
.option("--json", "output as JSON.", false)
.action(async (components, opts) => {
try {
const cwd = path.resolve(opts.cwd)
const config = await getConfig(cwd)
const base = resolveDocsBase(opts.base, config?.style)
const index = await getShadcnRegistryIndex()
if (!index) {
logger.error("Failed to fetch the registry index.")
process.exit(1)
}
const results: {
component: string
base: PresetBase
links: Record<string, string>
}[] = []
for (const component of components) {
const item = index.find((entry) => entry.name === component)
if (!item) {
logger.error(
`Component ${highlighter.info(
component
)} not found in the shadcn registry.`
)
process.exit(1)
}
const links = (
item.meta?.links as Record<string, Record<string, string>>
)?.[base]
if (!links || Object.keys(links).length === 0) {
logger.warn(
`No documentation links available for ${highlighter.info(
component
)}.`
)
continue
}
results.push({
component,
base,
links: normalizeLinks(links),
})
}
if (opts.json) {
console.log(JSON.stringify({ base, results }, null, 2))
return
}
// Compute max key length across all results for consistent alignment.
const maxKeyLength = Math.max(
...results.flatMap((r) => Object.keys(r.links).map((k) => k.length))
)
for (const { component, links } of results) {
logger.log(highlighter.info(component))
for (const [key, value] of Object.entries(links)) {
logger.log(` - ${key.padEnd(maxKeyLength + 2)}${value}`)
}
logger.break()
}
} catch (error) {
handleError(error)
}
})
export function resolveDocsBase(base: unknown, style: string | undefined) {
const resolvedBase = base ?? getBase(style)
if (!isPresetBase(resolvedBase)) {
throw new Error(
`Invalid base: ${String(resolvedBase)}. Expected one of: ${PRESET_BASES.join(
", "
)}.`
)
}
return resolvedBase
}
function normalizeLinks(links: Record<string, string>) {
return Object.fromEntries(
Object.entries(links).map(([key, value]) => [
key,
value.startsWith(SHADCN_BASE_URL)
? `${SHADCN_URL}${value.slice(SHADCN_BASE_URL.length)}`
: value,
])
)
}

View File

@@ -0,0 +1,262 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises"
import os from "os"
import path from "path"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { getPackageManager } from "@/src/utils/get-package-manager"
import { execa } from "execa"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { runEject, SHADCN_TAILWIND_IMPORT } from "./eject"
vi.mock("execa", () => ({
execa: vi.fn(),
}))
vi.mock("@/src/utils/get-package-manager", () => ({
getPackageManager: vi.fn().mockResolvedValue("pnpm"),
}))
vi.mock("@/src/utils/get-monorepo-info", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/src/utils/get-monorepo-info")>()
return {
...actual,
formatMonorepoMessage: vi.fn(),
getMonorepoTargets: vi.fn().mockResolvedValue([]),
isMonorepoRoot: vi.fn().mockResolvedValue(false),
}
})
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn(() => ({
start: vi.fn().mockReturnThis(),
succeed: vi.fn(),
})),
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
break: vi.fn(),
error: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
},
}))
const fixtureConfig = {
$schema: "https://ui.shadcn.com/schema.json",
style: "base-nova",
rsc: true,
tsx: true,
tailwind: {
config: "",
css: "app/globals.css",
baseColor: "neutral",
cssVariables: true,
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
ui: "@/components/ui",
lib: "@/lib",
hooks: "@/hooks",
},
}
const fixtureTsconfig = {
compilerOptions: {
baseUrl: ".",
paths: {
"@/*": ["./*"],
},
},
}
const baseCss = `@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@layer base {
body {
@apply bg-background text-foreground;
}
}
`
describe("runEject", () => {
let tempDir: string
beforeEach(async () => {
vi.clearAllMocks()
vi.mocked(getPackageManager).mockResolvedValue("pnpm")
tempDir = await mkdtemp(path.join(os.tmpdir(), "shadcn-eject-"))
})
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true })
})
async function setupProject(
cssContent: string,
packageJson: Record<string, unknown> = {
name: "test-app",
dependencies: {
shadcn: "^4.8.3",
},
}
) {
await writeFile(
path.join(tempDir, "components.json"),
JSON.stringify(fixtureConfig, null, 2)
)
await writeFile(
path.join(tempDir, "tsconfig.json"),
JSON.stringify(fixtureTsconfig, null, 2)
)
await mkdir(path.join(tempDir, "app"), { recursive: true })
await writeFile(path.join(tempDir, "app", "globals.css"), cssContent)
await writeFile(
path.join(tempDir, "package.json"),
JSON.stringify(packageJson, null, 2)
)
}
it("inlines shadcn/tailwind.css and removes the dependency", async () => {
await setupProject(baseCss)
await runEject({
cwd: tempDir,
yes: true,
silent: true,
})
const output = await readFile(
path.join(tempDir, "app", "globals.css"),
"utf8"
)
const shadcnCss = await readFile(
path.join(process.cwd(), "src/tailwind.css"),
"utf8"
)
expect(output).not.toMatch(SHADCN_TAILWIND_IMPORT)
expect(output).toContain('@import "tailwindcss";')
expect(output).toContain('@import "tw-animate-css";')
expect(output).toContain("/* ejected from shadcn@4.8.3 */")
expect(output).toContain(shadcnCss.trim())
expect(output).toContain("@layer base")
expect(output).toContain("@apply bg-background text-foreground;")
expect(execa).toHaveBeenCalledWith("pnpm", ["remove", "shadcn"], {
cwd: tempDir,
})
})
it("removes shadcn from devDependencies", async () => {
await setupProject(baseCss, {
name: "test-app",
devDependencies: {
shadcn: "^4.8.3",
},
})
await runEject({
cwd: tempDir,
yes: true,
silent: true,
})
const output = await readFile(
path.join(tempDir, "app", "globals.css"),
"utf8"
)
expect(output).toContain("/* ejected from shadcn@4.8.3 */")
expect(execa).toHaveBeenCalledWith("pnpm", ["remove", "shadcn"], {
cwd: tempDir,
})
})
it("skips dependency removal when shadcn is not in package.json", async () => {
await setupProject(baseCss, {
name: "test-app",
dependencies: {},
})
await runEject({
cwd: tempDir,
yes: true,
silent: true,
})
expect(execa).not.toHaveBeenCalled()
})
it("removes shadcn from package.json for deno", async () => {
vi.mocked(getPackageManager).mockResolvedValue("deno")
await setupProject(baseCss)
await runEject({
cwd: tempDir,
yes: true,
silent: true,
})
const packageJson = JSON.parse(
await readFile(path.join(tempDir, "package.json"), "utf8")
)
expect(packageJson.dependencies?.shadcn).toBeUndefined()
expect(execa).not.toHaveBeenCalled()
})
it("exits when shadcn/tailwind.css is not imported", async () => {
await setupProject(`@import "tailwindcss";
@import "tw-animate-css";
`)
const exit = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit")
}) as never)
await expect(
runEject({
cwd: tempDir,
yes: true,
silent: true,
})
).rejects.toThrow("process.exit")
exit.mockRestore()
})
it("shows monorepo suggestions when run from a monorepo root", async () => {
vi.mocked(isMonorepoRoot).mockResolvedValue(true)
vi.mocked(getMonorepoTargets).mockResolvedValue([
{ name: "packages/ui", hasConfig: true },
{ name: "apps/web", hasConfig: true },
])
const exit = vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit")
}) as never)
await expect(
runEject({
cwd: tempDir,
yes: true,
silent: true,
})
).rejects.toThrow("process.exit")
expect(formatMonorepoMessage).toHaveBeenCalledWith("eject", [
{ name: "packages/ui", hasConfig: true },
{ name: "apps/web", hasConfig: true },
])
exit.mockRestore()
})
})

View File

@@ -0,0 +1,248 @@
import { promises as fs } from "fs"
import path from "path"
import { SHADCN_URL } from "@/src/registry/constants"
import { getConfig } from "@/src/utils/get-config"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { getPackageInfo } from "@/src/utils/get-package-info"
import { getPackageManager } from "@/src/utils/get-package-manager"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { Command } from "commander"
import { execa } from "execa"
import fsExtra from "fs-extra"
import prompts from "prompts"
import { z } from "zod"
export const SHADCN_TAILWIND_IMPORT =
/@import\s+["']shadcn\/tailwind\.css["'];?\s*\n?/
export const ejectOptionsSchema = z.object({
cwd: z.string(),
yes: z.boolean(),
silent: z.boolean(),
})
export const eject = new Command()
.name("eject")
.description("inline shadcn/tailwind.css and remove the shadcn dependency")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("-y, --yes", "skip confirmation prompt.", false)
.option("-s, --silent", "mute output.", false)
.action(async (opts) => {
try {
const options = ejectOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
yes: opts.yes,
silent: opts.silent,
})
await runEject(options)
} catch (error) {
handleError(error)
}
})
export async function runEject(options: z.infer<typeof ejectOptionsSchema>) {
if (!fsExtra.existsSync(path.resolve(options.cwd, "components.json"))) {
if (await isMonorepoRoot(options.cwd)) {
const targets = await getMonorepoTargets(options.cwd)
if (targets.length > 0) {
formatMonorepoMessage("eject", targets)
process.exit(1)
}
}
logger.break()
logger.error(
`No ${highlighter.info("components.json")} found. Run ${highlighter.info("shadcn init")} first.`
)
logger.error(
`Learn more at ${highlighter.info(`${SHADCN_URL}/docs/components-json`)}.`
)
logger.break()
process.exit(1)
}
const config = await getConfig(options.cwd)
if (!config?.resolvedPaths.tailwindCss) {
logger.break()
logger.error(
"Could not resolve the Tailwind CSS file from components.json."
)
logger.break()
process.exit(1)
}
const cssFilepath = config.resolvedPaths.tailwindCss
const cssFilepathRelative = path.relative(options.cwd, cssFilepath)
let cssContent = await fs.readFile(cssFilepath, "utf8")
if (!SHADCN_TAILWIND_IMPORT.test(cssContent)) {
logger.break()
logger.error(
`Could not find ${highlighter.info('@import "shadcn/tailwind.css"')} in ${highlighter.info(cssFilepathRelative)}.`
)
logger.error("Nothing to eject.")
logger.break()
process.exit(1)
}
const packageInfo = getPackageInfo(options.cwd, false)
const shadcnVersion = getShadcnVersion(packageInfo)
const shadcnCssPath = resolveShadcnTailwindCss(options.cwd)
const shadcnCssContent = await fs.readFile(shadcnCssPath, "utf8")
if (!options.silent) {
logger.break()
logger.warn(
"This action is not reversible. Future shadcn CLI updates to tailwind.css will not apply automatically."
)
logger.break()
}
if (!options.yes) {
logger.log("This will:")
logger.log(
` - Inline ${highlighter.info("shadcn/tailwind.css")} into ${highlighter.info(cssFilepathRelative)}`
)
logger.log(` - Remove the ${highlighter.info("shadcn")} dependency`)
logger.break()
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Proceed?",
initial: false,
})
if (!proceed) {
process.exit(0)
}
}
const ejectSpinner = spinner(
`Inlining ${highlighter.info("shadcn/tailwind.css")}.`,
{
silent: options.silent,
}
)?.start()
cssContent = cssContent.replace(
SHADCN_TAILWIND_IMPORT,
() =>
`/* ejected from shadcn@${shadcnVersion} */\n${shadcnCssContent.trim()}\n\n`
)
await fs.writeFile(cssFilepath, cssContent, "utf8")
ejectSpinner?.succeed()
if (hasShadcnDependency(packageInfo)) {
const removeSpinner = spinner(`Removing ${highlighter.info("shadcn")}.`, {
silent: options.silent,
})?.start()
await removeShadcnDependency(options.cwd)
removeSpinner?.succeed()
} else if (!options.silent) {
logger.warn(
`The ${highlighter.info("shadcn")} package was not found in package.json. Skipped removal.`
)
}
logger.break()
logger.log(
`Ejected ${highlighter.info("shadcn/tailwind.css")} into ${highlighter.info(cssFilepathRelative)}.`
)
logger.break()
}
function getShadcnVersion(packageInfo: ReturnType<typeof getPackageInfo>) {
if (!packageInfo) {
return "unknown"
}
return (
packageInfo.dependencies?.shadcn ??
packageInfo.devDependencies?.shadcn ??
"unknown"
)
.replace(/^[\^~]/, "")
.trim()
}
function hasShadcnDependency(packageInfo: ReturnType<typeof getPackageInfo>) {
if (!packageInfo) {
return false
}
return Boolean(
packageInfo.dependencies?.shadcn || packageInfo.devDependencies?.shadcn
)
}
function resolveShadcnTailwindCss(cwd: string) {
const projectCss = path.join(cwd, "node_modules/shadcn/dist/tailwind.css")
if (fsExtra.existsSync(projectCss)) {
return projectCss
}
const cliRoot = process.argv[1]
? path.dirname(path.resolve(process.argv[1]))
: cwd
for (const candidate of [
path.join(cliRoot, "tailwind.css"),
path.join(cliRoot, "dist", "tailwind.css"),
path.join(cliRoot, "src", "tailwind.css"),
path.join(process.cwd(), "src/tailwind.css"),
path.join(process.cwd(), "dist/tailwind.css"),
]) {
if (fsExtra.existsSync(candidate)) {
return candidate
}
}
throw new Error("Could not resolve shadcn/tailwind.css.")
}
async function removeShadcnDependency(cwd: string) {
const packageManager = await getPackageManager(cwd)
switch (packageManager) {
case "npm":
await execa("npm", ["uninstall", "shadcn"], { cwd })
break
case "pnpm":
await execa("pnpm", ["remove", "shadcn"], { cwd })
break
case "yarn":
await execa("yarn", ["remove", "shadcn"], { cwd })
break
case "bun":
await execa("bun", ["remove", "shadcn"], { cwd })
break
case "deno": {
const packageJsonPath = path.join(cwd, "package.json")
const packageJson = await fsExtra.readJson(packageJsonPath)
for (const field of ["dependencies", "devDependencies"] as const) {
if (packageJson[field]?.shadcn) {
delete packageJson[field].shadcn
}
}
await fsExtra.writeJson(packageJsonPath, packageJson, { spaces: 2 })
break
}
}
}

View File

@@ -0,0 +1,299 @@
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 {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import {
getProjectComponents,
getProjectInfo,
type ProjectInfo,
} from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { Command } from "commander"
const CODE_BASE = `${SHADCN_URL}/code/apps/v4/registry/bases`
export const info = new Command()
.name("info")
.description("get information about your project")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("--json", "output as JSON.", false)
.action(async (opts) => {
try {
const cwd = path.resolve(opts.cwd)
// Check if we're in a monorepo root.
if (
!existsSync(path.resolve(cwd, "components.json")) &&
(await isMonorepoRoot(cwd))
) {
const targets = await getMonorepoTargets(cwd)
if (targets.length > 0) {
if (opts.json) {
console.log(
JSON.stringify(
{
error: "monorepo_root",
message:
"You are running info from a monorepo root. Use the -c flag to specify a workspace.",
targets: targets.map((t) => t.name),
},
null,
2
)
)
} else {
formatMonorepoMessage("info", targets)
}
process.exit(1)
}
}
const projectInfo = await getProjectInfo(cwd)
const config = await getConfig(cwd)
const components = await getProjectComponents(cwd)
const base = getBase(config?.style)
const data = await collectInfo(projectInfo, config, components, base)
if (opts.json) {
console.log(JSON.stringify(data, null, 2))
return
}
printInfo(data)
} catch (error) {
handleError(error)
}
})
function getRegistries(
registries: Record<string, string | { url: string }> | undefined
) {
if (!registries) {
return {}
}
const result: Record<string, string> = {}
for (const [name, value] of Object.entries(registries)) {
result[name] = typeof value === "string" ? value : value.url
}
return result
}
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
? {
framework: projectInfo.framework.label,
frameworkName: projectInfo.framework.name,
frameworkVersion: projectInfo.frameworkVersion ?? null,
srcDirectory: projectInfo.isSrcDir,
rsc: projectInfo.isRSC,
typescript: projectInfo.isTsx,
tailwindVersion: projectInfo.tailwindVersion ?? null,
tailwindConfig: projectInfo.tailwindConfigFile ?? null,
tailwindCss: projectInfo.tailwindCssFile ?? null,
importAlias: projectInfo.aliasPrefix ?? null,
}
: null,
config: config
? {
style: config.style,
base,
rsc: config.rsc,
typescript: config.tsx,
iconLibrary: config.iconLibrary ?? null,
rtl: config.rtl ?? false,
menuColor: config.menuColor ?? null,
menuAccent: config.menuAccent ?? null,
aliases: {
components: config.aliases.components,
utils: config.aliases.utils,
ui: config.aliases.ui ?? null,
lib: config.aliases.lib ?? null,
hooks: config.aliases.hooks ?? null,
},
resolvedPaths: {
cwd: config.resolvedPaths.cwd,
tailwindConfig: config.resolvedPaths.tailwindConfig || null,
tailwindCss: config.resolvedPaths.tailwindCss || null,
utils: config.resolvedPaths.utils,
components: config.resolvedPaths.components,
lib: config.resolvedPaths.lib,
hooks: config.resolvedPaths.hooks,
ui: config.resolvedPaths.ui,
},
registries: getRegistries(config.registries),
}
: null,
preset,
components,
links: {
docs: `${SHADCN_URL}/docs`,
components: `${SHADCN_URL}/docs/components/${base}/[component].md`,
ui: `${CODE_BASE}/${base}/ui/[component].tsx`,
examples: `${CODE_BASE}/${base}/examples/[component]-example.tsx`,
schema: `${SHADCN_URL}/schema.json`,
},
}
}
export function printInfo(data: Awaited<ReturnType<typeof collectInfo>>) {
// Project.
logger.log(highlighter.info("Project"))
if (data.project) {
printEntries({
framework: `${data.project.framework} (${data.project.frameworkName})`,
frameworkVersion: data.project.frameworkVersion ?? "-",
srcDirectory: data.project.srcDirectory ? "Yes" : "No",
rsc: data.project.rsc ? "Yes" : "No",
typescript: data.project.typescript ? "Yes" : "No",
tailwindVersion: data.project.tailwindVersion ?? "-",
tailwindConfig: data.project.tailwindConfig ?? "-",
tailwindCss: data.project.tailwindCss ?? "-",
importAlias: data.project.importAlias ?? "-",
})
} else {
logger.log(" No project info detected.")
}
// Config.
logger.break()
logger.log(highlighter.info("Configuration"))
if (data.config) {
printEntries({
style: data.config.style,
base: data.config.base,
rsc: data.config.rsc ? "Yes" : "No",
typescript: data.config.typescript ? "Yes" : "No",
iconLibrary: data.config.iconLibrary ?? "-",
rtl: data.config.rtl ? "Yes" : "No",
menuColor: data.config.menuColor ?? "-",
menuAccent: data.config.menuAccent ?? "-",
})
logger.break()
printPresetInfo(data.preset)
// Aliases.
logger.break()
logger.log(highlighter.info("Aliases"))
printEntries({
components: data.config.aliases.components,
utils: data.config.aliases.utils,
ui: data.config.aliases.ui ?? "-",
lib: data.config.aliases.lib ?? "-",
hooks: data.config.aliases.hooks ?? "-",
})
// Resolved paths.
logger.break()
logger.log(highlighter.info("Resolved Paths"))
printEntries({
cwd: data.config.resolvedPaths.cwd,
tailwindConfig: data.config.resolvedPaths.tailwindConfig ?? "-",
tailwindCss: data.config.resolvedPaths.tailwindCss ?? "-",
utils: data.config.resolvedPaths.utils,
components: data.config.resolvedPaths.components,
lib: data.config.resolvedPaths.lib,
hooks: data.config.resolvedPaths.hooks,
ui: data.config.resolvedPaths.ui,
})
// Registries.
if (Object.keys(data.config.registries).length > 0) {
logger.break()
logger.log("registries:")
printEntries(data.config.registries)
}
} else {
logger.log(" No components.json found.")
}
// Installed components.
logger.break()
logger.log(highlighter.info("Installed Components"))
if (data.components.length > 0) {
logger.log(` ${data.components.join(", ")}`)
} else {
logger.log(" No components installed.")
}
// Links.
logger.break()
logger.log(highlighter.info("Links"))
printEntries(data.links)
logger.break()
}
export function printPresetInfo(
preset: Awaited<ReturnType<typeof collectInfo>>["preset"],
options: {
fallbackNote?: string
} = {}
) {
logger.log(highlighter.info("Preset"))
if (!preset?.code) {
printEntries({
code: "-",
})
} else {
const fallbacks = preset.fallbacks ?? []
const formatPresetValue = (key: string, value: string | undefined) => {
const suffix = fallbacks.includes(key) ? "*" : ""
return `${value ?? "-"}${suffix}`
}
printEntries({
code: preset.code,
version: preset.code[0],
style: preset.values?.style ?? "-",
baseColor: formatPresetValue("baseColor", preset.values?.baseColor),
theme: formatPresetValue("theme", preset.values?.theme),
chartColor: formatPresetValue("chartColor", preset.values?.chartColor),
iconLibrary: formatPresetValue("iconLibrary", preset.values?.iconLibrary),
font: formatPresetValue("font", preset.values?.font),
fontHeading: formatPresetValue("fontHeading", preset.values?.fontHeading),
radius: formatPresetValue("radius", preset.values?.radius),
menuAccent: formatPresetValue("menuAccent", preset.values?.menuAccent),
menuColor: formatPresetValue("menuColor", preset.values?.menuColor),
url: `${SHADCN_URL}/create?preset=${preset.code}`,
})
if (fallbacks.length > 0) {
logger.log("")
logger.log(
options.fallbackNote ??
" * Uses preset defaults for values not available as options on shadcn/create."
)
}
}
}
function printEntries(entries: Record<string, string>) {
const maxKeyLength = Math.max(
...Object.keys(entries).map((key) => key.length)
)
for (const [key, value] of Object.entries(entries)) {
logger.log(` ${key.padEnd(maxKeyLength + 2)}${value}`)
}
}

View File

@@ -0,0 +1,298 @@
import { mkdir, mkdtemp, rm } from "fs/promises"
import os from "os"
import path from "path"
import { preFlightInit } from "@/src/preflights/preflight-init"
import { templates } from "@/src/templates"
import { addComponents } from "@/src/utils/add-components"
import { createProject } from "@/src/utils/create-project"
import { MISSING_DIR_OR_EMPTY_PROJECT } from "@/src/utils/errors"
import {
getProjectConfig,
getProjectInfo,
getProjectTailwindVersionFromConfig,
} from "@/src/utils/get-project-info"
import { ensureRegistriesInConfig } from "@/src/utils/registries"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { applyInitUrlOptions, init, initOptionsSchema, runInit } from "./init"
vi.mock("@/src/preflights/preflight-init", () => ({
preFlightInit: vi.fn(),
}))
vi.mock("@/src/utils/create-project", () => ({
createProject: vi.fn(),
}))
vi.mock("@/src/utils/add-components", () => ({
addComponents: vi.fn(),
}))
vi.mock("@/src/utils/registries", () => ({
ensureRegistriesInConfig: vi.fn(),
}))
vi.mock("@/src/registry/api", () => ({
getRegistryBaseColors: vi.fn().mockResolvedValue([
{
label: "Zinc",
name: "zinc",
},
]),
getRegistryStyles: vi.fn().mockResolvedValue([
{
label: "New York",
name: "new-york",
},
]),
}))
vi.mock("@/src/utils/get-config", () => ({
DEFAULT_COMPONENTS: "@/components",
DEFAULT_TAILWIND_CONFIG: "tailwind.config.js",
DEFAULT_TAILWIND_CSS: "app/globals.css",
DEFAULT_UTILS: "@/lib/utils",
explorer: {
clearCaches: vi.fn(),
},
getConfig: vi.fn(),
getWorkspaceConfig: vi.fn().mockResolvedValue(null),
resolveConfigPaths: vi.fn(
async (cwd: string, config: Record<string, unknown>) => ({
...config,
resolvedPaths: {
cwd,
tailwindConfig: path.resolve(cwd, "tailwind.config.js"),
tailwindCss: path.resolve(cwd, "src/index.css"),
utils: path.resolve(cwd, "src/lib/utils.ts"),
components: path.resolve(cwd, "src/components"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
ui: path.resolve(cwd, "src/components/ui"),
},
})
),
}))
vi.mock("@/src/utils/get-project-info", () => ({
getProjectComponents: vi.fn().mockResolvedValue([]),
getProjectConfig: vi.fn(),
getProjectInfo: vi.fn(),
getProjectTailwindVersionFromConfig: vi.fn(),
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
break: vi.fn(),
error: vi.fn(),
info: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
},
}))
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn(() => ({
fail: vi.fn(),
start: vi.fn().mockReturnThis(),
succeed: vi.fn(),
})),
}))
vi.mock("@/src/utils/highlighter", () => ({
highlighter: {
error: (value: string) => value,
info: (value: string) => value,
success: (value: string) => value,
warn: (value: string) => value,
},
}))
vi.mock("prompts", () => ({
default: vi.fn(),
}))
const projectInfo = {
framework: {
label: "Vite",
links: {},
name: "vite",
},
frameworkVersion: null,
isRSC: false,
isSrcDir: true,
isTsx: true,
tailwindConfigFile: null,
tailwindCssFile: "src/index.css",
tailwindVersion: "v4",
aliasPrefix: "@",
}
function createProjectConfig(cwd: string) {
return {
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
css: "src/index.css",
baseColor: "zinc",
cssVariables: true,
prefix: "",
},
iconLibrary: "lucide",
rtl: false,
aliases: {
components: "@/components",
ui: "@/components/ui",
hooks: "@/hooks",
lib: "@/lib",
utils: "@/lib/utils",
},
resolvedPaths: {
cwd,
tailwindConfig: "",
tailwindCss: path.resolve(cwd, "src/index.css"),
utils: path.resolve(cwd, "src/lib/utils.ts"),
components: path.resolve(cwd, "src/components"),
lib: path.resolve(cwd, "src/lib"),
hooks: path.resolve(cwd, "src/hooks"),
ui: path.resolve(cwd, "src/components/ui"),
},
}
}
function createInitOptions(cwd: string) {
return {
cwd,
yes: true,
defaults: true,
force: false,
reinstall: false,
silent: true,
isNewProject: false,
cssVariables: true,
installStyleIndex: true,
template: "vite",
} as Parameters<typeof runInit>[0]
}
describe("runInit", () => {
let cwd: string
let originalPostInit: typeof templates.vite.postInit
beforeEach(async () => {
cwd = await mkdtemp(path.join(os.tmpdir(), "shadcn-init-test-"))
originalPostInit = templates.vite.postInit
vi.mocked(getProjectInfo).mockResolvedValue(projectInfo as any)
vi.mocked(getProjectTailwindVersionFromConfig).mockResolvedValue("v4")
vi.mocked(getProjectConfig).mockImplementation(async (projectCwd) =>
createProjectConfig(projectCwd)
)
vi.mocked(ensureRegistriesInConfig).mockImplementation(
async (_components, config) => ({
config,
newRegistries: [],
discoveredRegistries: {},
packageJsonRegistries: {},
})
)
vi.mocked(addComponents).mockResolvedValue(undefined)
})
afterEach(async () => {
templates.vite.postInit = originalPostInit
vi.clearAllMocks()
await rm(cwd, { recursive: true, force: true })
})
it("does not run template postInit for existing projects with an explicit template", async () => {
const postInit = vi.fn()
templates.vite.postInit = postInit
vi.mocked(preFlightInit).mockResolvedValue({
errors: {},
projectInfo: projectInfo as any,
})
await runInit(createInitOptions(cwd))
expect(postInit).not.toHaveBeenCalled()
})
it("runs template postInit after creating a new project", async () => {
const projectPath = path.join(cwd, "vite-app")
await mkdir(projectPath)
const postInit = vi.fn()
templates.vite.postInit = postInit
vi.mocked(preFlightInit).mockResolvedValue({
errors: {
[MISSING_DIR_OR_EMPTY_PROJECT]: true,
},
projectInfo: null,
})
vi.mocked(createProject).mockResolvedValue({
projectPath,
projectName: "vite-app",
template: "vite",
})
await runInit(createInitOptions(cwd))
expect(postInit).toHaveBeenCalledWith({ projectPath })
})
it("does not run template postInit when isNewProject is true but createProject was skipped", async () => {
const postInit = vi.fn()
templates.vite.postInit = postInit
await runInit({
...createInitOptions(cwd),
skipPreflight: true,
isNewProject: true,
})
expect(createProject).not.toHaveBeenCalled()
expect(postInit).not.toHaveBeenCalled()
})
})
describe("init options", () => {
it("accepts aria as a base", () => {
const result = initOptionsSchema.parse({
...createInitOptions("/tmp/project"),
base: "aria",
})
expect(result.base).toBe("aria")
expect(init.helpInformation()).toContain("(base, radix, aria)")
})
it("parses pointer flags", () => {
const result = initOptionsSchema.parse({
...createInitOptions("/tmp/project"),
pointer: true,
})
expect(result.pointer).toBe(true)
})
it("applies pointer to raw init urls", () => {
const url = applyInitUrlOptions(
new URL("https://ui.shadcn.com/init?preset=a0"),
{ pointer: true }
)
expect(url.searchParams.get("pointer")).toBe("true")
})
it("removes pointer from raw init urls when disabled", () => {
const url = applyInitUrlOptions(
new URL("https://ui.shadcn.com/init?preset=a0&pointer=true"),
{ pointer: false }
)
expect(url.searchParams.has("pointer")).toBe(false)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,265 @@
import { promises as fs } from "fs"
import path from "path"
import { server } from "@/src/mcp"
import { loadEnvFiles } from "@/src/utils/env-loader"
import { getConfig } from "@/src/utils/get-config"
import { getPackageManager } from "@/src/utils/get-package-manager"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { updateDependencies } from "@/src/utils/updaters/update-dependencies"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { Command } from "commander"
import deepmerge from "deepmerge"
import { execa } from "execa"
import fsExtra from "fs-extra"
import prompts from "prompts"
import z from "zod"
const SHADCN_MCP_VERSION = "latest"
const CLIENTS = [
{
name: "claude",
label: "Claude Code",
configPath: ".mcp.json",
config: {
mcpServers: {
shadcn: {
command: "npx",
args: [`shadcn@${SHADCN_MCP_VERSION}`, "mcp"],
},
},
},
},
{
name: "cursor",
label: "Cursor",
configPath: ".cursor/mcp.json",
config: {
mcpServers: {
shadcn: {
command: "npx",
args: [`shadcn@${SHADCN_MCP_VERSION}`, "mcp"],
},
},
},
},
{
name: "vscode",
label: "VS Code",
configPath: ".vscode/mcp.json",
config: {
servers: {
shadcn: {
command: "npx",
args: [`shadcn@${SHADCN_MCP_VERSION}`, "mcp"],
},
},
},
},
{
name: "codex",
label: "Codex",
configPath: ".codex/config.toml",
config: `[mcp_servers.shadcn]
command = "npx"
args = ["shadcn@${SHADCN_MCP_VERSION}", "mcp"]
`,
},
{
name: "opencode",
label: "OpenCode",
configPath: "opencode.json",
config: {
$schema: "https://opencode.ai/config.json",
mcp: {
shadcn: {
type: "local",
command: ["npx", `shadcn@${SHADCN_MCP_VERSION}`, "mcp"],
enabled: true,
},
},
},
},
] as const
const DEPENDENCIES = [`shadcn@${SHADCN_MCP_VERSION}`]
export const mcp = new Command()
.name("mcp")
.description("MCP server and configuration commands")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.action(async (options) => {
try {
await loadEnvFiles(options.cwd)
const transport = new StdioServerTransport()
await server.connect(transport)
} catch (error) {
logger.break()
handleError(error)
}
})
const mcpInitOptionsSchema = z.object({
client: z.enum(["claude", "cursor", "vscode", "codex", "opencode"]),
cwd: z.string(),
})
mcp
.command("init")
.description("Initialize MCP configuration for your client")
.option(
"--client <client>",
`MCP client (${CLIENTS.map((c) => c.name).join(", ")})`
)
.action(async (opts, command) => {
try {
// Get the cwd from parent command.
const parentOpts = command.parent?.opts() || {}
const cwd = parentOpts.cwd || process.cwd()
let client = opts.client
if (!client) {
const response = await prompts({
type: "select",
name: "client",
message: "Which MCP client are you using?",
choices: CLIENTS.map((c) => ({
title: c.label,
value: c.name,
})),
})
if (!response.client) {
logger.break()
process.exit(1)
}
client = response.client
}
const options = mcpInitOptionsSchema.parse({
client,
cwd,
})
const config = await getConfig(options.cwd)
if (options.client === "codex") {
if (config) {
await updateDependencies([], DEPENDENCIES, config, {
silent: false,
})
} else {
const packageManager = await getPackageManager(options.cwd)
const installCommand = packageManager === "npm" ? "install" : "add"
const devFlag = packageManager === "npm" ? "--save-dev" : "-D"
const installSpinner = spinner("Installing dependencies...").start()
await execa(
packageManager,
[installCommand, devFlag, ...DEPENDENCIES],
{
cwd: options.cwd,
}
)
installSpinner.succeed("Installing dependencies.")
}
logger.break()
logger.log("To configure the shadcn MCP server in Codex:")
logger.break()
logger.log(
`1. Open or create the file ${highlighter.info(
"~/.codex/config.toml"
)}`
)
logger.log("2. Add the following configuration:")
logger.log()
logger.info(`[mcp_servers.shadcn]
command = "npx"
args = ["shadcn@${SHADCN_MCP_VERSION}", "mcp"]`)
logger.break()
logger.info("3. Restart Codex to load the MCP server")
logger.break()
process.exit(0)
}
const configSpinner = spinner("Configuring MCP server...").start()
const configPath = await runMcpInit(options)
configSpinner.succeed("Configuring MCP server.")
if (config) {
await updateDependencies([], DEPENDENCIES, config, {
silent: false,
})
} else {
const packageManager = await getPackageManager(options.cwd)
const installCommand = packageManager === "npm" ? "install" : "add"
const devFlag = packageManager === "npm" ? "--save-dev" : "-D"
const installSpinner = spinner("Installing dependencies...").start()
await execa(
packageManager,
[installCommand, devFlag, ...DEPENDENCIES],
{
cwd: options.cwd,
}
)
installSpinner.succeed("Installing dependencies.")
}
logger.break()
logger.success(`Configuration saved to ${configPath}.`)
logger.break()
} catch (error) {
handleError(error)
}
})
const overwriteMerge = (_: any[], sourceArray: any[]) => sourceArray
async function runMcpInit(options: z.infer<typeof mcpInitOptionsSchema>) {
const { client, cwd } = options
const clientInfo = CLIENTS.find((c) => c.name === client)
if (!clientInfo) {
throw new Error(
`Unknown client: ${client}. Available clients: ${CLIENTS.map(
(c) => c.name
).join(", ")}`
)
}
const configPath = path.join(cwd, clientInfo.configPath)
const dir = path.dirname(configPath)
await fsExtra.ensureDir(dir)
// Handle JSON format.
let existingConfig = {}
try {
const content = await fs.readFile(configPath, "utf-8")
existingConfig = JSON.parse(content)
} catch {}
const mergedConfig = deepmerge(
existingConfig,
clientInfo.config as Record<string, unknown>,
{ arrayMerge: overwriteMerge }
)
await fs.writeFile(
configPath,
JSON.stringify(mergedConfig, null, 2) + "\n",
"utf-8"
)
return clientInfo.configPath
}

View File

@@ -0,0 +1,151 @@
import path from "path"
import { migrateBaseColor } from "@/src/migrations/migrate-base-color"
import { migrateCn } from "@/src/migrations/migrate-cn"
import { migrateIcons } from "@/src/migrations/migrate-icons"
import { migrateRadix } from "@/src/migrations/migrate-radix"
import { migrateRtl } from "@/src/migrations/migrate-rtl"
import { preFlightMigrate } from "@/src/preflights/preflight-migrate"
import * as ERRORS from "@/src/utils/errors"
import { handleError } from "@/src/utils/handle-error"
import { logger } from "@/src/utils/logger"
import { Command } from "commander"
import { z } from "zod"
export const migrations = [
{
name: "cn",
description: "migrate clsx and tailwind-merge to cn.",
},
{
name: "icons",
description: "migrate your ui components to a different icon library.",
},
{
name: "base-color",
description: "migrate your theme to a different base color.",
},
{
name: "radix",
description: "migrate to radix-ui.",
},
{
name: "rtl",
description: "migrate your components to support RTL (right-to-left).",
},
] as const
export const migrateOptionsSchema = z.object({
cwd: z.string(),
list: z.boolean(),
yes: z.boolean(),
from: z.string().optional(),
to: z.string().optional(),
migration: z
.string()
.refine(
(value) =>
value && migrations.some((migration) => migration.name === value),
{
message:
"You must specify a valid migration. Run `shadcn migrate --list` to see available migrations.",
}
)
.optional(),
path: z.string().optional(),
})
export const migrate = new Command()
.name("migrate")
.description("run a migration.")
.argument("[migration]", "the migration to run.")
.argument("[path]", "optional path or glob pattern to migrate.")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("-l, --list", "list all migrations.", false)
.option("-y, --yes", "skip confirmation prompt.", false)
.option(
"-f, --from <name>",
"the base color or icon library to migrate from."
)
.option("-t, --to <name>", "the base color or icon library to migrate to.")
.action(async (migration, migratePath, opts) => {
try {
const options = migrateOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
migration,
path: migratePath,
list: opts.list,
yes: opts.yes,
from: opts.from,
to: opts.to,
})
if (options.list || !options.migration) {
logger.info("Available migrations:")
for (const migration of migrations) {
logger.info(`- ${migration.name}: ${migration.description}`)
}
return
}
if (options.migration === "cn") {
await migrateCn({
cwd: options.cwd,
path: options.path,
yes: options.yes,
})
return
}
const { errors, config } = await preFlightMigrate(options)
if (errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
throw new Error(
"No `components.json` file found. Ensure you are at the root of your project."
)
}
if (errors[ERRORS.MISSING_CONFIG]) {
throw new Error(
"No `components.json` file found. Ensure you are at the root of your project."
)
}
if (!config) {
throw new Error(
"Something went wrong reading your `components.json` file. Please ensure you have a valid `components.json` file."
)
}
if (options.migration === "icons") {
await migrateIcons(config, {
from: options.from,
to: options.to,
path: options.path,
yes: options.yes,
})
}
if (options.migration === "base-color") {
await migrateBaseColor(config, {
from: options.from,
to: options.to,
yes: options.yes,
})
}
if (options.migration === "radix") {
await migrateRadix(config, { yes: options.yes, path: options.path })
}
if (options.migration === "rtl") {
await migrateRtl(config, { yes: options.yes, path: options.path })
}
} catch (error) {
logger.break()
handleError(error)
}
})

View File

@@ -0,0 +1,492 @@
import { existsSync } from "fs"
import path from "path"
import { resolveProjectPreset } from "@/src/preset/resolve"
import { getConfig } from "@/src/utils/get-config"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { getProjectInfo } from "@/src/utils/get-project-info"
import { logger } from "@/src/utils/logger"
import open from "open"
import { beforeEach, describe, expect, it, vi } from "vitest"
import {
decodePresetCode,
getPresetUrl,
openCommand,
preset as presetCommand,
printPresetDecode,
resolve as resolveCommand,
url,
} from "./preset"
vi.mock("fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("fs")>()
return {
...actual,
existsSync: vi.fn(),
}
})
vi.mock("open", () => ({
default: vi.fn(),
}))
vi.mock("@/src/preset/resolve", () => ({
resolveProjectPreset: vi.fn(),
}))
vi.mock("@/src/utils/get-config", () => ({
getBase: vi.fn(() => "radix"),
getConfig: vi.fn(),
}))
vi.mock("@/src/utils/get-monorepo-info", () => ({
formatMonorepoMessage: vi.fn(),
getMonorepoTargets: vi.fn(),
isMonorepoRoot: vi.fn(),
}))
vi.mock("@/src/utils/get-project-info", () => ({
getProjectComponents: vi.fn(),
getProjectInfo: vi.fn(),
}))
vi.mock("@/src/utils/handle-error", () => ({
handleError: vi.fn((error) => {
throw error
}),
}))
vi.mock("@/src/utils/highlighter", () => ({
highlighter: {
info: (value: string) => value,
},
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
break: vi.fn(),
error: vi.fn(),
log: vi.fn(),
},
}))
describe("preset commands", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(existsSync).mockReturnValue(true)
vi.mocked(isMonorepoRoot).mockResolvedValue(false)
vi.mocked(getMonorepoTargets).mockResolvedValue([])
vi.mocked(getConfig).mockResolvedValue(
{} as Awaited<ReturnType<typeof getConfig>>
)
vi.mocked(getProjectInfo).mockResolvedValue(
{} as Awaited<ReturnType<typeof getProjectInfo>>
)
vi.mocked(resolveProjectPreset).mockResolvedValue({
code: "b123",
fallbacks: [],
values: {
style: "luma",
baseColor: "mist",
theme: "blue",
chartColor: "emerald",
iconLibrary: "phosphor",
font: "inter",
fontHeading: "lora",
radius: "large",
menuAccent: "bold",
menuColor: "inverted",
},
})
})
it("decodes current preset codes", () => {
const result = decodePresetCode("b0")
expect(result).toEqual({
code: "b0",
version: "b",
values: {
style: "nova",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
radius: "default",
menuAccent: "subtle",
menuColor: "default",
},
derived: [],
url: getPresetUrl("b0"),
})
})
it("applies compatibility values for older preset codes", () => {
const result = decodePresetCode("a0")
expect(result.version).toBe("a")
expect(result.values.chartColor).toBe("blue")
expect(result.derived).toEqual(["chartColor"])
})
it("prints compatibility markers in human output", () => {
printPresetDecode(decodePresetCode("a0"))
expect(logger.log).toHaveBeenCalledWith("Preset")
expect(logger.log).toHaveBeenCalledWith(" code a0")
expect(logger.log).toHaveBeenCalledWith(" version a")
expect(logger.log).toHaveBeenCalledWith(" chartColor blue*")
expect(logger.log).toHaveBeenCalledWith(
` url ${getPresetUrl("a0")}`
)
expect(logger.log).toHaveBeenCalledWith(
" * Compatibility value for older preset versions."
)
})
it("matches decoded preset output ordering to resolved preset output", () => {
printPresetDecode(decodePresetCode("b0"))
expect(logger.log).toHaveBeenCalledWith("Preset")
expect(vi.mocked(logger.log).mock.calls.map((call) => call[0])).toEqual([
"Preset",
" code b0",
" version b",
" style nova",
" baseColor neutral",
" theme neutral",
" chartColor neutral",
" iconLibrary lucide",
" font inter",
" fontHeading inherit",
" radius default",
" menuAccent subtle",
" menuColor default",
` url ${getPresetUrl("b0")}`,
])
})
it("prints decoded preset JSON with derived values", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
await presetCommand.parseAsync(["decode", "a0", "--json"], { from: "user" })
expect(JSON.parse(log.mock.calls[0][0] as string)).toEqual({
code: "a0",
version: "a",
values: {
style: "nova",
baseColor: "neutral",
theme: "neutral",
chartColor: "blue",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
radius: "default",
menuAccent: "subtle",
menuColor: "default",
},
derived: ["chartColor"],
url: getPresetUrl("a0"),
})
log.mockRestore()
})
it("rejects invalid codes", () => {
expect(() => decodePresetCode("c0")).toThrow("Invalid preset code: c0")
})
it("rejects URLs", () => {
expect(() =>
decodePresetCode("https://ui.shadcn.com/create?preset=a0")
).toThrow("Invalid preset code: https://ui.shadcn.com/create?preset=a0")
})
it("prints the create URL from preset url", async () => {
await url.parseAsync(["a0"], { from: "user" })
expect(logger.break).not.toHaveBeenCalled()
expect(logger.log).toHaveBeenCalledWith(getPresetUrl("a0"))
})
it("prints the create URL before opening it", async () => {
vi.mocked(open).mockImplementation(async () => {
expect(logger.log).toHaveBeenCalledWith(
` Opening ${getPresetUrl("a0")} in your browser.`
)
return {} as Awaited<ReturnType<typeof open>>
})
await openCommand.parseAsync(["a0"], { from: "user" })
expect(logger.break).toHaveBeenCalledTimes(2)
expect(logger.log).toHaveBeenCalledWith(
` Opening ${getPresetUrl("a0")} in your browser.`
)
expect(open).toHaveBeenCalledWith(getPresetUrl("a0"))
})
it("does not open invalid preset codes", async () => {
const exit = mockProcessExit()
await expect(
openCommand.parseAsync(["https://ui.shadcn.com/create?preset=a0"], {
from: "user",
})
).rejects.toThrow("process.exit:1")
expect(logger.error).toHaveBeenCalledWith(
"Invalid preset code: https://ui.shadcn.com/create?preset=a0"
)
expect(open).not.toHaveBeenCalled()
exit.mockRestore()
})
it("fails when opening the browser fails", async () => {
const exit = mockProcessExit()
vi.mocked(open).mockRejectedValue(new Error("open failed"))
await expect(
openCommand.parseAsync(["a0"], { from: "user" })
).rejects.toThrow("process.exit:1")
expect(logger.log).toHaveBeenCalledWith(
` Opening ${getPresetUrl("a0")} in your browser.`
)
expect(logger.error).toHaveBeenCalledWith(
"Failed to open preset URL: open failed"
)
exit.mockRestore()
})
it("resolves a project preset using the existing resolver", async () => {
await resolveCommand.parseAsync([], { from: "user" })
expect(logger.log).toHaveBeenCalledWith("Preset")
expect(logger.log).toHaveBeenCalledWith(" code b123")
expect(logger.log).toHaveBeenCalledWith(" version b")
expect(logger.log).toHaveBeenCalledWith(expect.stringContaining("b123"))
expect(logger.log).toHaveBeenCalledWith(
expect.stringContaining("https://ui.shadcn.com/create?preset=b123")
)
expect(resolveProjectPreset).toHaveBeenCalledWith({}, {})
})
it("prints fallback markers when resolving a project preset", async () => {
vi.mocked(resolveProjectPreset).mockResolvedValueOnce({
code: "b123",
fallbacks: ["theme"],
values: {
style: "luma",
baseColor: "mist",
theme: "neutral",
chartColor: "emerald",
iconLibrary: "phosphor",
font: "inter",
fontHeading: "lora",
radius: "large",
menuAccent: "bold",
menuColor: "inverted",
},
})
await resolveCommand.parseAsync([], { from: "user" })
expect(logger.log).toHaveBeenCalledWith(expect.stringContaining("neutral*"))
expect(logger.log).toHaveBeenCalledWith(
" * Uses preset defaults for values not available as options on shadcn/create."
)
})
it("prints the resolved preset as JSON", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
await resolveCommand.parseAsync(["--json"], { from: "user" })
expect(JSON.parse(log.mock.calls[0][0] as string)).toEqual({
code: "b123",
fallbacks: [],
values: {
style: "luma",
baseColor: "mist",
theme: "blue",
chartColor: "emerald",
iconLibrary: "phosphor",
font: "inter",
fontHeading: "lora",
radius: "large",
menuAccent: "bold",
menuColor: "inverted",
},
})
log.mockRestore()
})
it("prints fallback metadata in resolved preset JSON", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
vi.mocked(resolveProjectPreset).mockResolvedValueOnce({
code: "b123",
fallbacks: ["theme"],
values: {
style: "luma",
baseColor: "mist",
theme: "neutral",
chartColor: "emerald",
iconLibrary: "phosphor",
font: "inter",
fontHeading: "lora",
radius: "large",
menuAccent: "bold",
menuColor: "inverted",
},
})
await resolveCommand.parseAsync(["--json"], { from: "user" })
expect(JSON.parse(log.mock.calls[0][0] as string)).toMatchObject({
fallbacks: ["theme"],
values: {
theme: "neutral",
},
})
log.mockRestore()
})
it("supports preset info as an alias for preset resolve", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
await presetCommand.parseAsync(["info", "--json"], { from: "user" })
expect(JSON.parse(log.mock.calls[0][0] as string)).toMatchObject({
code: "b123",
})
log.mockRestore()
})
it("resolves presets from the provided cwd", async () => {
await resolveCommand.parseAsync(["--cwd", "apps/web"], { from: "user" })
expect(getConfig).toHaveBeenCalledWith(path.resolve("apps/web"))
expect(getProjectInfo).toHaveBeenCalledWith(path.resolve("apps/web"))
})
it("prints no-config output when no components.json exists", async () => {
vi.mocked(existsSync).mockReturnValue(false)
vi.mocked(getConfig).mockResolvedValueOnce(null)
await resolveCommand.parseAsync([], { from: "user" })
expect(logger.log).toHaveBeenCalledWith("No components.json found.")
})
it("prints null JSON when no components.json exists", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
vi.mocked(existsSync).mockReturnValue(false)
vi.mocked(getConfig).mockResolvedValueOnce(null)
await resolveCommand.parseAsync(["--json"], { from: "user" })
expect(JSON.parse(log.mock.calls[0][0] as string)).toBeNull()
log.mockRestore()
})
it("prints null JSON when no preset can be resolved", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
vi.mocked(resolveProjectPreset).mockResolvedValueOnce({
code: null,
fallbacks: [],
values: null,
})
await resolveCommand.parseAsync(["--json"], { from: "user" })
expect(JSON.parse(log.mock.calls[0][0] as string)).toBeNull()
log.mockRestore()
})
it("matches info monorepo-root behavior", async () => {
const exit = mockProcessExit()
const targets = [{ name: "apps/web" }]
vi.mocked(existsSync).mockReturnValue(false)
vi.mocked(isMonorepoRoot).mockResolvedValue(true)
vi.mocked(getMonorepoTargets).mockResolvedValue(targets as never)
await expect(
resolveCommand.parseAsync([], { from: "user" })
).rejects.toThrow("process.exit:1")
expect(formatMonorepoMessage).toHaveBeenCalledWith(
"preset resolve",
targets
)
expect(getConfig).not.toHaveBeenCalled()
exit.mockRestore()
})
it("prints monorepo-root JSON for preset resolve --json", async () => {
const exit = mockProcessExit()
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const targets = [{ name: "apps/web" }]
vi.mocked(existsSync).mockReturnValue(false)
vi.mocked(isMonorepoRoot).mockResolvedValue(true)
vi.mocked(getMonorepoTargets).mockResolvedValue(targets as never)
await expect(
resolveCommand.parseAsync(["--json"], { from: "user" })
).rejects.toThrow("process.exit:1")
expect(JSON.parse(log.mock.calls[0][0] as string)).toEqual({
error: "monorepo_root",
message:
"You are running preset resolve from a monorepo root. Use the -c flag to specify a workspace.",
targets: ["apps/web"],
})
expect(formatMonorepoMessage).not.toHaveBeenCalled()
expect(getConfig).not.toHaveBeenCalled()
log.mockRestore()
exit.mockRestore()
})
it("prints help when no preset subcommand is provided", async () => {
const outputHelp = vi
.spyOn(presetCommand, "outputHelp")
.mockImplementation(() => {})
await presetCommand.parseAsync([], { from: "user" })
expect(outputHelp).toHaveBeenCalledOnce()
outputHelp.mockRestore()
})
it("errors on unknown preset subcommands", async () => {
const exit = mockProcessExit()
const outputHelp = vi
.spyOn(presetCommand, "outputHelp")
.mockImplementation(() => {})
const writeErr = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true)
await expect(
presetCommand.parseAsync(["bogus"], { from: "user" })
).rejects.toThrow("process.exit:1")
expect(outputHelp).not.toHaveBeenCalled()
expect(writeErr.mock.calls.join("")).toContain("too many arguments")
writeErr.mockRestore()
outputHelp.mockRestore()
exit.mockRestore()
})
})
function mockProcessExit() {
return vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit:${code}`)
})
}

View File

@@ -0,0 +1,231 @@
import { existsSync } from "fs"
import path from "path"
import { printPresetInfo } from "@/src/commands/info"
import {
decodePreset,
V1_CHART_COLOR_MAP,
type PresetConfig,
} from "@/src/preset/preset"
import { resolveProjectPreset } from "@/src/preset/resolve"
import { SHADCN_URL } from "@/src/registry/constants"
import { getConfig } from "@/src/utils/get-config"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { getProjectInfo } from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
import { logger } from "@/src/utils/logger"
import { Command } from "commander"
import open from "open"
type PresetValues = Omit<PresetConfig, "chartColor"> & {
chartColor: NonNullable<PresetConfig["chartColor"]>
}
type PresetDecodeResult = {
code: string
version: string
values: PresetValues
derived: string[]
url: string
}
export function getPresetUrl(code: string) {
return `${SHADCN_URL}/create?preset=${code}`
}
export function decodePresetCode(code: string): PresetDecodeResult {
const decoded = decodePreset(code)
if (!decoded) {
throw new Error(`Invalid preset code: ${code}`)
}
const derived: string[] = []
const chartColor = (decoded.chartColor ??
V1_CHART_COLOR_MAP[decoded.theme] ??
decoded.theme) as PresetValues["chartColor"]
if (!decoded.chartColor) {
derived.push("chartColor")
}
return {
code,
version: code[0],
values: {
...decoded,
chartColor,
},
derived,
url: getPresetUrl(code),
}
}
export function printPresetDecode(result: PresetDecodeResult) {
printPresetInfo(
{
code: result.code,
fallbacks: result.derived,
values: result.values,
},
{
fallbackNote: " * Compatibility value for older preset versions.",
}
)
}
export const decode = new Command()
.name("decode")
.description("decode a preset code")
.argument("<code>", "the preset code to decode")
.option("--json", "output as JSON.", false)
.action((code, opts) => {
try {
const result = decodePresetCode(code)
if (opts.json) {
console.log(
JSON.stringify(
{
code: result.code,
version: result.version,
values: result.values,
derived: result.derived,
url: result.url,
},
null,
2
)
)
return
}
printPresetDecode(result)
} catch (error) {
handlePresetError(error)
}
})
export const url = new Command()
.name("url")
.description("get the create URL for a preset code")
.argument("<code>", "the preset code")
.action((code) => {
try {
logger.log(decodePresetCode(code).url)
} catch (error) {
handlePresetError(error)
}
})
export const openCommand = new Command()
.name("open")
.description("open a preset code in the browser")
.argument("<code>", "the preset code")
.action(async (code) => {
let presetUrl: string
try {
presetUrl = decodePresetCode(code).url
} catch (error) {
handlePresetError(error)
return
}
logger.break()
logger.log(` Opening ${presetUrl} in your browser.`)
logger.break()
try {
await open(presetUrl)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
handlePresetError(new Error(`Failed to open preset URL: ${message}`))
}
})
export const resolve = new Command()
.name("resolve")
.alias("info")
.description("resolve a preset from your project")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("--json", "output as JSON.", false)
.action(async (opts) => {
try {
const cwd = path.resolve(opts.cwd)
if (
!existsSync(path.resolve(cwd, "components.json")) &&
(await isMonorepoRoot(cwd))
) {
const targets = await getMonorepoTargets(cwd)
if (targets.length > 0) {
if (opts.json) {
console.log(
JSON.stringify(
{
error: "monorepo_root",
message:
"You are running preset resolve from a monorepo root. Use the -c flag to specify a workspace.",
targets: targets.map((target) => target.name),
},
null,
2
)
)
} else {
formatMonorepoMessage("preset resolve", targets)
}
process.exit(1)
}
}
const config = await getConfig(cwd)
if (!config) {
if (opts.json) {
console.log(JSON.stringify(null, null, 2))
return
}
logger.log("No components.json found.")
return
}
const projectInfo = await getProjectInfo(cwd)
const preset = await resolveProjectPreset(config, projectInfo)
if (opts.json) {
console.log(JSON.stringify(preset.code ? preset : null, null, 2))
return
}
printPresetInfo(preset)
} catch (error) {
handleError(error)
}
})
export const preset = new Command()
.name("preset")
.description("manage presets")
.addCommand(decode)
.addCommand(resolve)
.addCommand(url)
.addCommand(openCommand)
.action(() => {
preset.outputHelp()
})
function handlePresetError(error: unknown) {
if (error instanceof Error) {
logger.error(error.message)
}
process.exit(1)
}

View File

@@ -0,0 +1,214 @@
import { tmpdir } from "os"
import path from "path"
import fs from "fs-extra"
import { describe, expect, it } from "vitest"
import { addRegistriesToConfig, parseRegistryArg } from "./add"
describe("parseRegistryArg", () => {
it("should parse namespace without URL", () => {
expect(parseRegistryArg("@magicui")).toEqual({ namespace: "@magicui" })
expect(parseRegistryArg("@aceternity")).toEqual({
namespace: "@aceternity",
})
})
it("should parse namespace with URL", () => {
expect(
parseRegistryArg("@mycompany=https://example.com/r/{name}.json")
).toEqual({
namespace: "@mycompany",
url: "https://example.com/r/{name}.json",
})
})
it("should handle URL with query params containing =", () => {
expect(
parseRegistryArg("@foo=https://example.com/r/{name}.json?token=abc")
).toEqual({
namespace: "@foo",
url: "https://example.com/r/{name}.json?token=abc",
})
})
it("should handle URL with multiple = in query params", () => {
expect(
parseRegistryArg(
"@bar=https://example.com/r/{name}.json?token=abc&key=xyz"
)
).toEqual({
namespace: "@bar",
url: "https://example.com/r/{name}.json?token=abc&key=xyz",
})
})
it("should handle URL with port number", () => {
expect(
parseRegistryArg("@local=http://localhost:8080/r/{name}.json")
).toEqual({
namespace: "@local",
url: "http://localhost:8080/r/{name}.json",
})
})
it("should throw for namespace without @", () => {
expect(() => parseRegistryArg("foo")).toThrow("must start with @")
expect(() => parseRegistryArg("magicui")).toThrow("must start with @")
expect(() =>
parseRegistryArg("mycompany=https://example.com/r/{name}.json")
).toThrow("must start with @")
})
})
describe("addRegistriesToConfig", () => {
it("should write registries to components.json when it exists", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const componentsJsonFile = path.join(tempDir, "components.json")
await fs.writeJson(componentsJsonFile, { style: "new-york" })
try {
await addRegistriesToConfig(
["@acme=https://acme.com/r/{name}.json"],
tempDir,
{ silent: true }
)
const config = await fs.readJson(componentsJsonFile)
expect(config).toEqual({
style: "new-york",
registries: {
"@acme": "https://acme.com/r/{name}.json",
},
})
} finally {
await fs.rm(tempDir, { recursive: true })
}
})
it("should prefer components.json over package.json when both exist", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const componentsJsonFile = path.join(tempDir, "components.json")
const packageJsonFile = path.join(tempDir, "package.json")
await fs.writeJson(componentsJsonFile, { style: "new-york" })
await fs.writeJson(packageJsonFile, { name: "test-package" })
try {
await addRegistriesToConfig(
["@acme=https://acme.com/r/{name}.json"],
tempDir,
{ silent: true }
)
const componentsJson = await fs.readJson(componentsJsonFile)
const packageJson = await fs.readJson(packageJsonFile)
expect(componentsJson.registries).toEqual({
"@acme": "https://acme.com/r/{name}.json",
})
expect(packageJson.registries).toBeUndefined()
} finally {
await fs.rm(tempDir, { recursive: true })
}
})
it("should write registries to package.json when components.json does not exist", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const packageJsonFile = path.join(tempDir, "package.json")
await fs.writeJson(packageJsonFile, {
name: "test-package",
version: "1.0.0",
})
try {
await addRegistriesToConfig(
["@acme=https://acme.com/r/{name}.json"],
tempDir,
{ silent: true }
)
const packageJson = await fs.readJson(packageJsonFile)
expect(packageJson).toEqual({
name: "test-package",
version: "1.0.0",
registries: {
"@acme": "https://acme.com/r/{name}.json",
},
})
} finally {
await fs.rm(tempDir, { recursive: true })
}
})
it("should preserve existing registries when adding to package.json", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const packageJsonFile = path.join(tempDir, "package.json")
await fs.writeJson(packageJsonFile, {
name: "test-package",
registries: {
"@existing": "https://existing.com/r/{name}.json",
},
})
try {
await addRegistriesToConfig(
["@acme=https://acme.com/r/{name}.json"],
tempDir,
{ silent: true }
)
const packageJson = await fs.readJson(packageJsonFile)
expect(packageJson.registries).toEqual({
"@existing": "https://existing.com/r/{name}.json",
"@acme": "https://acme.com/r/{name}.json",
})
} finally {
await fs.rm(tempDir, { recursive: true })
}
})
it("should skip registries already configured in package.json", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
const packageJsonFile = path.join(tempDir, "package.json")
await fs.writeJson(packageJsonFile, {
name: "test-package",
registries: {
"@acme": "https://existing.com/r/{name}.json",
},
})
try {
await addRegistriesToConfig(
["@acme=https://new.com/r/{name}.json"],
tempDir,
{ silent: true }
)
const packageJson = await fs.readJson(packageJsonFile)
expect(packageJson.registries).toEqual({
"@acme": "https://existing.com/r/{name}.json",
})
} finally {
await fs.rm(tempDir, { recursive: true })
}
})
it("should throw when neither components.json nor package.json exists", async () => {
const tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-test-"))
try {
await expect(
addRegistriesToConfig(
["@acme=https://acme.com/r/{name}.json"],
tempDir,
{ silent: true }
)
).rejects.toThrow(/No .*components\.json.* or .*package\.json.* found/)
} finally {
await fs.rm(tempDir, { recursive: true })
}
})
})

View File

@@ -0,0 +1,254 @@
import path from "path"
import { getRegistries } from "@/src/registry/api"
import { BUILTIN_REGISTRIES } from "@/src/registry/constants"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { Command } from "commander"
import fs from "fs-extra"
import prompts from "prompts"
import { z } from "zod"
const addOptionsSchema = z.object({
cwd: z.string(),
silent: z.boolean(),
})
export const add = new Command()
.name("add")
.description("add registries to your project")
.argument(
"[registries...]",
"registries (@namespace) or registry URLs (@namespace=url)"
)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("-s, --silent", "mute output.", false)
.action(async (registries: string[], opts) => {
try {
const options = addOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
silent: opts.silent,
})
const registryArgs =
registries.length > 0
? registries
: await promptForRegistries({ silent: options.silent })
await addRegistriesToConfig(registryArgs, options.cwd, {
silent: options.silent,
})
} catch (error) {
logger.break()
handleError(error)
}
})
export function parseRegistryArg(arg: string): {
namespace: string
url?: string
} {
const [namespace, ...rest] = arg.split("=")
const url = rest.length > 0 ? rest.join("=") : undefined
if (!namespace.startsWith("@")) {
throw new Error(
`Invalid registry namespace: ${highlighter.info(namespace)}. ` +
`Registry names must start with @ (e.g., @acme).`
)
}
return { namespace, url }
}
function pluralize(count: number, singular: string, plural: string) {
return `${count} ${count === 1 ? singular : plural}`
}
export async function addRegistriesToConfig(
registryArgs: string[],
cwd: string,
options: { silent?: boolean }
) {
// Write to components.json when it exists, otherwise fall back to
// package.json. This mirrors how registries are resolved.
const configPath = ["components.json", "package.json"]
.map((file) => path.resolve(cwd, file))
.find((file) => fs.existsSync(file))
if (!configPath) {
throw new Error(
`No ${highlighter.info("components.json")} or ${highlighter.info(
"package.json"
)} found. Run ${highlighter.info("shadcn init")} first.`
)
}
const configFileName = path.basename(configPath)
const parsed = registryArgs.map(parseRegistryArg)
const needsLookup = parsed.filter((p) => !p.url)
let registriesIndex: { name: string; url: string }[] = []
if (needsLookup.length > 0) {
const fetchSpinner = spinner("Fetching registries.", {
silent: options.silent,
}).start()
const registries = await getRegistries()
if (!registries) {
fetchSpinner.fail()
throw new Error("Failed to fetch registries.")
}
fetchSpinner.succeed()
registriesIndex = registries
}
const registriesToAdd: Record<string, string> = {}
for (const { namespace, url } of parsed) {
if (namespace in BUILTIN_REGISTRIES) {
logger.warn(
`${highlighter.info(
namespace
)} is a built-in registry and cannot be added.`
)
continue
}
if (url) {
if (!url.includes("{name}")) {
throw new Error(
`Invalid registry URL for ${highlighter.info(
namespace
)}. URL must include {name} placeholder. Example: ${highlighter.info(
`${namespace}=https://example.com/r/{name}.json`
)}`
)
}
registriesToAdd[namespace] = url
} else {
const registry = registriesIndex.find((r) => r.name === namespace)
if (!registry) {
throw new Error(
`Registry ${highlighter.info(namespace)} not found. ` +
`Provide a URL: ${highlighter.info(
`${namespace}=https://.../{name}.json`
)}`
)
}
registriesToAdd[namespace] = registry.url
}
}
if (Object.keys(registriesToAdd).length === 0) {
return { addedRegistries: [] }
}
const existingConfig = await fs.readJson(configPath)
const existingRegistries = existingConfig.registries || {}
const newRegistries: Record<string, string> = {}
const skipped: string[] = []
for (const [ns, url] of Object.entries(registriesToAdd)) {
if (existingRegistries[ns]) {
skipped.push(ns)
} else {
newRegistries[ns] = url
}
}
if (Object.keys(newRegistries).length === 0) {
if (skipped.length > 0 && !options.silent) {
spinner(
`Skipped ${pluralize(
skipped.length,
"registry",
"registries"
)}: (already configured)`,
{ silent: options.silent }
)?.info()
for (const name of skipped) {
logger.log(` - ${name}`)
}
} else if (!options.silent) {
logger.info("No new registries to add.")
}
return
}
const updatedConfig = {
...existingConfig,
registries: {
...existingRegistries,
...newRegistries,
},
}
const writeSpinner = spinner(`Updating ${configFileName}.`, {
silent: options.silent,
}).start()
await fs.writeJson(configPath, updatedConfig, { spaces: 2 })
writeSpinner.succeed()
if (!options.silent) {
const newRegistryNames = Object.keys(newRegistries)
spinner(
`Added ${pluralize(newRegistryNames.length, "registry", "registries")}:`,
{ silent: options.silent }
)?.succeed()
for (const name of newRegistryNames) {
logger.log(` - ${name}`)
}
if (skipped.length > 0) {
spinner(
`Skipped ${pluralize(
skipped.length,
"registry",
"registries"
)}: (already configured)`,
{ silent: options.silent }
)?.info()
for (const name of skipped) {
logger.log(` - ${name}`)
}
}
}
}
async function promptForRegistries(options: { silent?: boolean }) {
const fetchSpinner = spinner("Fetching registries.", {
silent: options.silent,
}).start()
const registries = await getRegistries()
if (!registries) {
fetchSpinner.fail()
throw new Error("Failed to fetch registries.")
}
fetchSpinner.succeed()
const sorted = [...registries].sort((a, b) => a.name.localeCompare(b.name))
const { selected } = await prompts({
type: "autocompleteMultiselect",
name: "selected",
message: "Which registries would you like to add?",
hint: "Space to select. A to toggle all. Enter to submit.",
instructions: false,
choices: sorted.map((r) => ({
title: r.name,
description: r.description,
value: r.name,
})),
})
if (!selected?.length) {
logger.warn("No registries selected. Exiting.")
logger.info("")
process.exit(1)
}
return selected as string[]
}

View File

@@ -0,0 +1,224 @@
import * as fs from "fs/promises"
import * as path from "path"
import { preFlightRegistryBuild } from "@/src/preflights/preflight-registry"
import { SHADCN_URL } from "@/src/registry/constants"
import { recursivelyResolveFileImports } from "@/src/registry/utils"
import { configSchema, registryItemSchema, registrySchema } from "@/src/schema"
import * as ERRORS from "@/src/utils/errors"
import { getProjectInfo, ProjectInfo } from "@/src/utils/get-project-info"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { Command } from "commander"
import { z } from "zod"
export const buildOptionsSchema = z.object({
cwd: z.string(),
registryFile: z.string(),
outputDir: z.string(),
verbose: z.boolean().optional().default(false),
})
export const build = new Command()
.name("registry:build")
.description("builds the registry [EXPERIMENTAL]")
.argument("[registry]", "path to registry.json file", "./registry.json")
.option(
"-o, --output <path>",
"destination directory for json files",
"./public/r"
)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("-v, --verbose", "verbose output")
.action(async (registry: string, opts) => {
await buildRegistry({
cwd: path.resolve(opts.cwd),
registryFile: registry,
outputDir: opts.output,
verbose: opts.verbose,
})
})
async function buildRegistry(opts: z.infer<typeof buildOptionsSchema>) {
try {
const options = buildOptionsSchema.parse(opts)
const [{ errors, resolvePaths, config }, projectInfo] = await Promise.all([
preFlightRegistryBuild(options),
getProjectInfo(options.cwd),
])
if (errors[ERRORS.MISSING_CONFIG] || !config || !projectInfo) {
logger.error(
`A ${highlighter.info(
"components.json"
)} file is required to build the registry. Run ${highlighter.info(
"shadcn init"
)} to create one.`
)
logger.break()
process.exit(1)
}
if (errors[ERRORS.BUILD_MISSING_REGISTRY_FILE] || !resolvePaths) {
logger.error(
`We could not find a registry file at ${highlighter.info(
path.resolve(options.cwd, options.registryFile)
)}.`
)
logger.break()
process.exit(1)
}
const content = await fs.readFile(resolvePaths.registryFile, "utf-8")
const result = registrySchema.safeParse(JSON.parse(content))
if (!result.success) {
logger.error(
`Invalid registry file found at ${highlighter.info(
resolvePaths.registryFile
)}.`
)
logger.break()
process.exit(1)
}
const buildSpinner = spinner("Building registry...")
// Recursively resolve the registry items.
const resolvedRegistry = await resolveRegistryItems(
result.data,
config,
projectInfo
)
// Loop through the registry items and remove duplicates files i.e same path.
for (const registryItem of resolvedRegistry.items) {
// Deduplicate files
registryItem.files = registryItem.files?.filter(
(file, index, self) =>
index === self.findIndex((t) => t.path === file.path)
)
// Deduplicate dependencies
if (registryItem.dependencies) {
registryItem.dependencies = registryItem.dependencies.filter(
(dep, index, self) => index === self.findIndex((d) => d === dep)
)
}
}
for (const registryItem of resolvedRegistry.items) {
if (!registryItem.files) {
continue
}
buildSpinner.start(`Building ${registryItem.name}...`)
// Add the schema to the registry item.
registryItem["$schema"] =
"https://ui.shadcn.com/schema/registry-item.json"
for (const file of registryItem.files) {
const absPath = path.resolve(resolvePaths.cwd, file.path)
try {
const stat = await fs.stat(absPath)
if (!stat.isFile()) {
continue
}
file["content"] = await fs.readFile(absPath, "utf-8")
} catch (err) {
console.error("Error reading file in registry build:", absPath, err)
continue
}
}
const result = registryItemSchema.safeParse(registryItem)
if (!result.success) {
logger.error(
`Invalid registry item found for ${highlighter.info(
registryItem.name
)}.`
)
continue
}
// Write the registry item to the output directory.
// Item names can contain path segments (e.g. "extension/foo"), so
// ensure the nested output directory exists before writing.
const outputPath = path.resolve(
resolvePaths.outputDir,
`${result.data.name}.json`
)
await fs.mkdir(path.dirname(outputPath), { recursive: true })
await fs.writeFile(outputPath, JSON.stringify(result.data, null, 2))
}
// Copy registry.json to the output directory.
await fs.copyFile(
resolvePaths.registryFile,
path.resolve(resolvePaths.outputDir, "registry.json")
)
buildSpinner.succeed("Building registry.")
if (options.verbose) {
spinner(
`The registry has ${highlighter.info(
resolvedRegistry.items.length.toString()
)} items:`
).succeed()
for (const item of resolvedRegistry.items) {
logger.log(` - ${item.name} (${highlighter.info(item.type)})`)
for (const file of item.files ?? []) {
logger.log(` - ${file.path}`)
}
}
}
} catch (error) {
logger.break()
handleError(error)
}
}
// This reads the registry and recursively resolves the file imports.
async function resolveRegistryItems(
registry: z.infer<typeof registrySchema>,
config: z.infer<typeof configSchema>,
projectInfo: ProjectInfo
): Promise<z.infer<typeof registrySchema>> {
for (const item of registry.items) {
if (!item.files?.length) {
continue
}
// Process all files in the array instead of just the first one
for (const file of item.files) {
const results = await recursivelyResolveFileImports(
file.path,
config,
projectInfo
)
// Remove file from results.files
results.files = results.files?.filter((f) => f.path !== file.path)
if (results.files) {
item.files.push(...results.files)
}
if (results.dependencies) {
item.dependencies = item.dependencies
? item.dependencies.concat(results.dependencies)
: results.dependencies
}
}
}
return registry
}

View File

@@ -0,0 +1,9 @@
import { add } from "@/src/commands/registry/add"
import { validate } from "@/src/commands/registry/validate"
import { Command } from "commander"
export const registry = new Command()
.name("registry")
.description("manage registries")
.addCommand(add)
.addCommand(validate)

View File

@@ -0,0 +1,22 @@
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { Command } from "commander"
export const mcp = new Command()
.name("registry:mcp")
.description("starts the registry MCP server [DEPRECATED]")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.action(async () => {
logger.warn(
`The ${highlighter.info(
"shadcn registry:mcp"
)} command is deprecated. Use the ${highlighter.info(
"shadcn mcp"
)} command instead.`
)
logger.break()
})

View File

@@ -0,0 +1,190 @@
import * as fs from "fs/promises"
import { tmpdir } from "os"
import * as path from "path"
import { validateGitHubRegistrySource } from "@/src/registry/github"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { validate } from "./validate"
vi.mock("@/src/utils/handle-error", () => ({
handleError: vi.fn((error) => {
throw error
}),
}))
vi.mock("@/src/utils/highlighter", () => ({
highlighter: {
error: (value: string) => value,
info: (value: string) => value,
success: (value: string) => value,
},
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
break: vi.fn(),
error: vi.fn(),
log: vi.fn(),
},
}))
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn(() => ({
fail: vi.fn(),
start: vi.fn().mockReturnThis(),
succeed: vi.fn(),
})),
}))
vi.mock("@/src/registry/github", () => ({
validateGitHubRegistrySource: vi.fn(async () => ({
valid: true,
cwd: "acme/ui#HEAD",
registryFiles: 1,
registryFilePaths: ["acme/ui#HEAD/registry.json"],
items: 2,
diagnostics: [],
})),
}))
describe("registry validate command", () => {
beforeEach(() => {
vi.clearAllMocks()
process.exitCode = undefined
})
it("prints success with checked counts", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
items: [],
}),
})
await validate.parseAsync(["registry.json", "--cwd", cwd], {
from: "user",
})
const validationSpinner = vi.mocked(spinner).mock.results[0].value
const summarySpinner = vi.mocked(spinner).mock.results[1].value
expect(validationSpinner.succeed).toHaveBeenCalledWith("Registry is valid.")
expect(spinner).toHaveBeenCalledWith("Checked 1 registry file and 0 items.")
expect(summarySpinner.succeed).toHaveBeenCalled()
expect(logger.log).toHaveBeenCalledWith(" - registry.json")
expect(
vi.mocked(logger.log).mock.calls.map(([message]) => message)
).toEqual([" - registry.json"])
expect(process.exitCode).toBeUndefined()
})
it("prints grouped diagnostics and sets a failing exit code", async () => {
const cwd = await createFixture({
"registry.json": JSON.stringify({
name: "example",
homepage: "https://example.com",
include: ["components/ui/registry.json"],
}),
"components/ui/registry.json": JSON.stringify({
items: [
{
name: "button",
type: "registry:ui",
files: [
{
path: "missing.tsx",
type: "registry:ui",
},
],
},
],
}),
})
await validate.parseAsync(["registry.json", "--cwd", cwd], {
from: "user",
})
const validationSpinner = vi.mocked(spinner).mock.results[0].value
expect(validationSpinner.fail).toHaveBeenCalledWith(
"Registry validation failed."
)
expect(spinner).toHaveBeenCalledTimes(1)
expect(logger.log).toHaveBeenCalledWith(
" Checked 2 registry files and 1 item."
)
expect(logger.log).toHaveBeenCalledWith(" - registry.json")
expect(logger.log).toHaveBeenCalledWith(" - components/ui/registry.json")
expect(logger.log).toHaveBeenCalledWith("components/ui/registry.json")
expect(logger.error).toHaveBeenCalledWith(
' - items[0] "button" file "missing.tsx": File "missing.tsx" was not found or could not be read.'
)
expect(
vi.mocked(logger.log).mock.calls.map(([message]) => message)
).toEqual([
" Checked 2 registry files and 1 item.",
" - registry.json",
" - components/ui/registry.json",
"components/ui/registry.json",
" Make sure the file path is relative to the registry.json file that declares the item.",
])
expect(process.exitCode).toBe(1)
})
it("validates a GitHub source registry", async () => {
await validate.parseAsync(["acme/ui"], {
from: "user",
})
expect(validateGitHubRegistrySource).toHaveBeenCalledWith({
owner: "acme",
repo: "ui",
ref: undefined,
})
const validationSpinner = vi.mocked(spinner).mock.results[0].value
const summarySpinner = vi.mocked(spinner).mock.results[1].value
expect(validationSpinner.succeed).toHaveBeenCalledWith("Registry is valid.")
expect(spinner).toHaveBeenCalledWith("Checked 1 registry file and 2 items.")
expect(summarySpinner.succeed).toHaveBeenCalled()
expect(logger.log).toHaveBeenCalledWith(" - registry.json")
expect(process.exitCode).toBeUndefined()
})
it("does not treat an existing local path as a GitHub source registry", async () => {
const cwd = await createFixture({
"acme/ui": JSON.stringify({
name: "example",
homepage: "https://example.com",
items: [],
}),
})
await validate.parseAsync(["acme/ui", "--cwd", cwd], {
from: "user",
})
expect(validateGitHubRegistrySource).not.toHaveBeenCalled()
const validationSpinner = vi.mocked(spinner).mock.results[0].value
expect(validationSpinner.fail).toHaveBeenCalledWith(
"Registry validation failed."
)
expect(logger.log).toHaveBeenCalledWith(" - acme/ui")
expect(process.exitCode).toBe(1)
})
})
async function createFixture(files: Record<string, string>) {
const cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-validate-command-"))
await Promise.all(
Object.entries(files).map(async ([filePath, content]) => {
const targetPath = path.join(cwd, filePath)
await fs.mkdir(path.dirname(targetPath), { recursive: true })
await fs.writeFile(targetPath, content)
})
)
return cwd
}

View File

@@ -0,0 +1,211 @@
import * as fs from "fs/promises"
import * as path from "path"
import { resolveGitHubRegistrySource } from "@/src/registry/address"
import { validateGitHubRegistrySource } from "@/src/registry/github"
import { validateRegistry } from "@/src/registry/validate"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { Command } from "commander"
import { z } from "zod"
const validateOptionsSchema = z.object({
cwd: z.string(),
registryFile: z.string(),
})
type RegistryValidationReport = Awaited<ReturnType<typeof validateRegistry>>
export const validate = new Command()
.name("validate")
.description("validate a shadcn registry")
.argument(
"[registry]",
"registry address to validate. Supports registry.json paths and GitHub sources.",
"./registry.json"
)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.action(async (registryFile: string, opts) => {
let validationSpinner: ReturnType<typeof spinner> | undefined
try {
const options = validateOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
registryFile,
})
const githubSource = await resolveGitHubRegistryValidationSource(
registryFile,
options.cwd
)
validationSpinner = spinner("Validating registry.").start()
const report = githubSource
? await validateGitHubRegistrySource(githubSource)
: await validateRegistry(options)
printRegistryValidationReport(report, validationSpinner)
if (!report.valid) {
process.exitCode = 1
}
} catch (error) {
validationSpinner?.fail("Registry validation failed.")
logger.break()
handleError(error)
}
})
function printRegistryValidationReport(
report: RegistryValidationReport,
validationSpinner: ReturnType<typeof spinner>
) {
if (report.valid) {
validationSpinner.succeed("Registry is valid.")
printRegistryValidationStats(report, { success: true })
return
}
validationSpinner.fail("Registry validation failed.")
printRegistryValidationStats(report)
logger.break()
for (const [registryFile, diagnostics] of Array.from(
groupDiagnostics(report)
)) {
logger.log(highlighter.info(formatPath(registryFile, report.cwd)))
for (const diagnostic of diagnostics) {
logger.error(` - ${formatDiagnostic(diagnostic)}`)
if (diagnostic.suggestion) {
logger.log(` ${diagnostic.suggestion}`)
}
}
logger.break()
}
}
function printRegistryValidationStats(
report: RegistryValidationReport,
options: {
success?: boolean
} = {}
) {
const message = `Checked ${formatCount(
report.registryFiles,
"registry file",
"registry files"
)} and ${formatCount(report.items, "item", "items")}.`
if (options.success) {
printSuccess(message)
} else {
logger.log(` ${message}`)
}
for (const registryFile of report.registryFilePaths) {
logger.log(` - ${formatPath(registryFile, report.cwd)}`)
}
}
function groupDiagnostics(report: RegistryValidationReport) {
const groups = new Map<string, typeof report.diagnostics>()
for (const diagnostic of report.diagnostics) {
const diagnostics = groups.get(diagnostic.registryFile) ?? []
diagnostics.push(diagnostic)
groups.set(diagnostic.registryFile, diagnostics)
}
return groups
}
function formatDiagnostic(
diagnostic: RegistryValidationReport["diagnostics"][number]
) {
const context = []
if (diagnostic.itemIndex !== undefined) {
context.push(`items[${diagnostic.itemIndex}]`)
}
if (diagnostic.itemName) {
context.push(`"${diagnostic.itemName}"`)
}
if (diagnostic.includePath) {
context.push(`include "${diagnostic.includePath}"`)
}
if (diagnostic.filePath) {
context.push(`file "${diagnostic.filePath}"`)
}
if (!context.length) {
return diagnostic.message
}
return `${context.join(" ")}: ${diagnostic.message}`
}
function formatPath(filePath: string, cwd: string) {
const relativePath = path.relative(cwd, filePath)
if (
relativePath &&
!relativePath.startsWith("..") &&
!path.isAbsolute(relativePath)
) {
return relativePath.split(path.sep).join("/")
}
if (!relativePath) {
return "."
}
return filePath
}
function printSuccess(message: string) {
spinner(message).succeed()
}
function formatCount(count: number, singular: string, plural: string) {
return `${count} ${count === 1 ? singular : plural}`
}
async function resolveGitHubRegistryValidationSource(
registry: string,
cwd: string
) {
if (isLocalRegistryPath(registry)) {
return null
}
if (await fileExists(path.resolve(cwd, registry))) {
return null
}
return resolveGitHubRegistrySource(registry)
}
function isLocalRegistryPath(registry: string) {
return (
registry.startsWith(".") ||
path.isAbsolute(registry) ||
registry.endsWith(".json")
)
}
async function fileExists(filePath: string) {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}

View File

@@ -0,0 +1,333 @@
import { searchRegistries } from "@/src/registry/search"
import { getConfig } from "@/src/utils/get-config"
import { ensureRegistriesInConfig } from "@/src/utils/registries"
import fsExtra from "fs-extra"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { search } from "./search"
const baseConfig = {
$schema: "",
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "",
css: "",
baseColor: "neutral",
cssVariables: true,
prefix: "",
},
aliases: {
components: "@/components",
ui: "@/components/ui",
hooks: "@/hooks",
lib: "@/lib",
utils: "@/lib/utils",
},
registries: {},
resolvedPaths: {
cwd: "/tmp/test-project",
tailwindConfig: "",
tailwindCss: "",
utils: "",
components: "",
lib: "",
hooks: "",
ui: "",
},
}
const mockResults = {
pagination: {
total: 2,
offset: 0,
limit: 100,
hasMore: false,
},
items: [
{
name: "button",
type: "registry:ui",
description: "A button component",
registry: "@shadcn",
addCommandArgument: "@shadcn/button",
},
{
name: "card",
type: "registry:ui",
registry: "@shadcn",
addCommandArgument: "@shadcn/card",
},
],
}
vi.mock("fs-extra", () => ({
default: {
existsSync: vi.fn(() => false),
readJson: vi.fn(),
},
}))
vi.mock("@/src/utils/env-loader", () => ({
loadEnvFiles: vi.fn(),
}))
vi.mock("@/src/utils/get-config", () => ({
createConfig: vi.fn(() => baseConfig),
getConfig: vi.fn(() => null),
}))
vi.mock("@/src/utils/registries", () => ({
ensureRegistriesInConfig: vi.fn(() => ({
config: baseConfig,
newRegistries: [],
discoveredRegistries: {},
packageJsonRegistries: {},
})),
}))
vi.mock("@/src/registry/validator", () => ({
validateRegistryConfigForItems: vi.fn(),
}))
// Stub searchRegistries but keep the real printSearchResults (both now live
// in @/src/registry/search) so the human-readable output is exercised.
vi.mock("@/src/registry/search", async (importActual) => ({
...(await importActual<typeof import("@/src/registry/search")>()),
searchRegistries: vi.fn(() => mockResults),
}))
vi.mock("@/src/registry/context", () => ({
clearRegistryContext: vi.fn(),
withRegistryContext: vi.fn((callback: () => unknown) => callback()),
}))
vi.mock("@/src/utils/handle-error", () => ({
handleError: vi.fn((error) => {
throw error
}),
}))
describe("search command", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("only discovers namespace registries for search inputs", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const exit = mockProcessExit()
await expect(
search.parseAsync(
[
"@acme",
"acme/ui",
"https://example.com/registry.json",
"--cwd",
"/tmp/test-project",
],
{
from: "user",
}
)
).rejects.toThrow("process.exit:0")
expect(ensureRegistriesInConfig).toHaveBeenCalledWith(
["@acme/registry"],
expect.any(Object),
{
silent: true,
writeFile: false,
}
)
log.mockRestore()
exit.mockRestore()
})
it("prints human-readable output by default", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const exit = mockProcessExit()
await expect(
search.parseAsync(["@shadcn", "--cwd", "/tmp/test-project"], {
from: "user",
})
).rejects.toThrow("process.exit:0")
expect(searchRegistries).toHaveBeenCalled()
expect(log).toHaveBeenCalledWith(
expect.stringContaining("Found 2 items in @shadcn")
)
expect(log).toHaveBeenCalledWith(expect.stringContaining("@shadcn/button"))
expect(log).not.toHaveBeenCalledWith(
expect.stringContaining('"pagination"')
)
log.mockRestore()
exit.mockRestore()
})
it("prints JSON output with --json", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const exit = mockProcessExit()
await expect(
search.parseAsync(["@shadcn", "--cwd", "/tmp/test-project", "--json"], {
from: "user",
})
).rejects.toThrow("process.exit:0")
expect(log).toHaveBeenCalledWith(JSON.stringify(mockResults, null, 2))
log.mockRestore()
exit.mockRestore()
})
it("requires a registry when no components.json is present", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const exit = mockProcessExit()
// fs-extra.existsSync is mocked to return false (no components.json).
// This is a usage error, so it prints a message and exits 1 directly
// instead of routing through handleError.
await expect(
search.parseAsync(["--cwd", "/tmp/test-project"], {
from: "user",
})
).rejects.toThrow("process.exit:1")
expect(log).toHaveBeenCalledWith(
expect.stringContaining("Provide a registry or namespace to search")
)
expect(searchRegistries).not.toHaveBeenCalled()
log.mockRestore()
exit.mockRestore()
})
it("requires a registry when components.json has no registries", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const exit = mockProcessExit()
vi.mocked(fsExtra.existsSync).mockReturnValueOnce(true as never)
vi.mocked(fsExtra.readJson).mockResolvedValueOnce({ style: "new-york" })
// components.json present but with no configured registries (only the
// builtin @shadcn, which is excluded from "search all").
vi.mocked(getConfig).mockReturnValueOnce({ ...baseConfig } as never)
await expect(
search.parseAsync(["--cwd", "/tmp/test-project"], {
from: "user",
})
).rejects.toThrow("process.exit:1")
expect(log).toHaveBeenCalledWith(
expect.stringContaining("No registries are configured")
)
expect(searchRegistries).not.toHaveBeenCalled()
log.mockRestore()
exit.mockRestore()
})
it("errors on an unknown --type", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const exit = mockProcessExit()
await expect(
search.parseAsync(
["@shadcn", "--type", "bogus", "--cwd", "/tmp/test-project"],
{
from: "user",
}
)
).rejects.toThrow("process.exit:1")
expect(log).toHaveBeenCalledWith(expect.stringContaining("Unknown type"))
expect(searchRegistries).not.toHaveBeenCalled()
log.mockRestore()
exit.mockRestore()
})
it("searches all configured registries when none are provided", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const exit = mockProcessExit()
vi.mocked(fsExtra.existsSync).mockReturnValueOnce(true as never)
// readJson returns a raw (unresolved) components.json shape.
vi.mocked(fsExtra.readJson).mockResolvedValueOnce({ style: "new-york" })
vi.mocked(getConfig).mockReturnValueOnce({
...baseConfig,
registries: {
"@acme": "https://acme.com/{name}.json",
"@internal": "https://internal.com/{name}.json",
},
} as never)
await expect(
search.parseAsync(["--cwd", "/tmp/test-project"], {
from: "user",
})
).rejects.toThrow("process.exit:0")
// No explicit namespace args, so nothing to discover.
expect(ensureRegistriesInConfig).toHaveBeenCalledWith(
[],
expect.any(Object),
expect.any(Object)
)
// Only the configured registries are searched (builtin @shadcn is
// excluded), and per-registry failures are tolerated.
expect(searchRegistries).toHaveBeenCalledWith(
["@acme", "@internal"],
expect.objectContaining({ continueOnError: true })
)
log.mockRestore()
exit.mockRestore()
})
it("exits non-zero when every registry fails in search-all", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {})
const exit = mockProcessExit()
vi.mocked(fsExtra.existsSync).mockReturnValueOnce(true as never)
vi.mocked(fsExtra.readJson).mockResolvedValueOnce({ style: "new-york" })
vi.mocked(getConfig).mockReturnValueOnce({
...baseConfig,
registries: {
"@acme": "https://acme.com/{name}.json",
"@internal": "https://internal.com/{name}.json",
},
} as never)
// Both configured registries failed to load.
vi.mocked(searchRegistries).mockReturnValueOnce({
pagination: { total: 0, offset: 0, limit: 0, hasMore: false },
items: [],
errors: [
{ registry: "@acme", message: "boom" },
{ registry: "@internal", message: "boom" },
],
} as never)
await expect(
search.parseAsync(["--cwd", "/tmp/test-project"], {
from: "user",
})
).rejects.toThrow("process.exit:1")
log.mockRestore()
exit.mockRestore()
})
})
function mockProcessExit() {
return vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit:${code}`)
})
}

View File

@@ -0,0 +1,234 @@
import path from "path"
import { configWithDefaults } from "@/src/registry/config"
import { BUILTIN_REGISTRIES } from "@/src/registry/constants"
import { clearRegistryContext } from "@/src/registry/context"
import {
findUnknownSearchTypes,
printSearchResults,
resolveSearchRegistries,
SEARCHABLE_TYPES,
searchRegistries,
} from "@/src/registry/search"
import { validateRegistryConfigForItems } from "@/src/registry/validator"
import { rawConfigSchema } from "@/src/schema"
import { loadEnvFiles } from "@/src/utils/env-loader"
import { createConfig, getConfig } from "@/src/utils/get-config"
import { handleError } from "@/src/utils/handle-error"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { ensureRegistriesInConfig } from "@/src/utils/registries"
import { Command } from "commander"
import fsExtra from "fs-extra"
import { z } from "zod"
const searchOptionsSchema = z.object({
cwd: z.string(),
query: z.string().optional(),
types: z.array(z.string()).optional(),
limit: z.number().optional(),
offset: z.number().optional(),
})
// TODO: We're duplicating logic for shadowConfig here.
// Revisit and properly abstract this.
export const search = new Command()
.name("search")
.alias("list")
.description("search items from registries")
.argument(
"[registries...]",
"the registry addresses to search. Supports namespaces, GitHub sources and URLs. When omitted, searches all registries configured in components.json."
)
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.option("-q, --query <query>", "query string")
.option(
"-t, --type <type>",
"filter by item type, e.g. ui, block, hook. Comma-separated for multiple."
)
.option("-l, --limit <number>", "maximum number of items to display", "100")
.option("-o, --offset <number>", "number of items to skip", "0")
.option("--json", "output as JSON.", false)
.action(async (registries: string[], opts) => {
try {
const options = searchOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
query: opts.query,
types: opts.type
? opts.type
.split(",")
.map((type: string) => type.trim())
.filter(Boolean)
: undefined,
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
})
// Validate type filters up front so an unknown type fails clearly
// instead of silently returning no results.
if (options.types?.length) {
const unknownTypes = findUnknownSearchTypes(options.types)
if (unknownTypes.length > 0) {
logger.break()
logger.error(
`Unknown ${unknownTypes.length === 1 ? "type" : "types"}: ${unknownTypes
.map((type) => highlighter.info(type))
.join(", ")}.`
)
logger.error(`Valid types: ${SEARCHABLE_TYPES.join(", ")}.`)
logger.break()
process.exit(1)
}
}
await loadEnvFiles(options.cwd)
// Start with a shadow config to support partial components.json.
// Use createConfig to get proper default paths
const defaultConfig = createConfig({
style: "new-york",
resolvedPaths: {
cwd: options.cwd,
},
})
let shadowConfig = configWithDefaults(defaultConfig)
// Check if there's a components.json file (partial or complete).
const componentsJsonPath = path.resolve(options.cwd, "components.json")
const hasComponentsJson = fsExtra.existsSync(componentsJsonPath)
if (hasComponentsJson) {
const existingConfig = await fsExtra.readJson(componentsJsonPath)
const partialConfig = rawConfigSchema.partial().parse(existingConfig)
shadowConfig = configWithDefaults({
...defaultConfig,
...partialConfig,
})
}
// Try to get the full config, but fall back to shadow config if it fails.
let config = shadowConfig
try {
const fullConfig = await getConfig(options.cwd)
if (fullConfig) {
config = configWithDefaults(fullConfig)
}
} catch {
// Use shadow config if getConfig fails (partial components.json).
}
// When no registry is provided, search across every registry configured
// in components.json. This only makes sense when a components.json is
// present to enumerate; otherwise there is nothing to search and we ask
// for an explicit registry/namespace argument.
const searchAllConfigured = registries.length === 0
if (searchAllConfigured && !hasComponentsJson) {
logger.break()
logger.error(
`Provide a registry or namespace to search, e.g. ${highlighter.info(
"shadcn search @shadcn"
)}.`
)
logger.break()
logger.error(
`If you have a ${highlighter.info(
"components.json"
)} with registries configured, run ${highlighter.info(
"shadcn search"
)} with no arguments to search all of them.`
)
logger.break()
process.exit(1)
}
// Only namespace registries that are not already configured need to be
// discovered and added to the config. Registries in components.json (or
// builtins) are resolved directly from the config below. Skipping
// configured registries also avoids the discovery step downloading the
// full catalog before the search request.
const { config: updatedConfig, newRegistries } =
await ensureRegistriesInConfig(
registries
.filter(
(registry) =>
registry.startsWith("@") &&
!config.registries?.[registry] &&
!(registry in BUILTIN_REGISTRIES)
)
.map((registry) => `${registry}/registry`),
config,
{
silent: true,
writeFile: false,
}
)
if (newRegistries.length > 0) {
config.registries = updatedConfig.registries
}
// When no registry is passed, "search all" resolves to every configured
// registry, excluding builtins (e.g. @shadcn).
const registriesToSearch = resolveSearchRegistries(registries, config)
if (searchAllConfigured && registriesToSearch.length === 0) {
logger.break()
logger.error(
`No registries are configured in ${highlighter.info(
"components.json"
)}.`
)
logger.error(
`Provide a registry or namespace to search, e.g. ${highlighter.info(
"shadcn search @shadcn"
)}.`
)
logger.break()
process.exit(1)
}
// For explicitly requested registries we validate up front so the user
// gets a clear error (e.g. missing env vars). When searching every
// configured registry we skip strict validation and instead tolerate
// individual registry failures (see continueOnError below).
if (!searchAllConfigured) {
validateRegistryConfigForItems(registriesToSearch, config)
}
const results = await searchRegistries(registriesToSearch, {
query: options.query,
types: options.types,
limit: options.limit,
offset: options.offset,
config,
// Tolerate per-registry failures when searching every configured
// registry; failures are returned in `results.errors` so they can be
// surfaced to humans (printSearchResults) and machines (--json) alike.
continueOnError: searchAllConfigured,
})
// In search-all mode, failures are tolerated and collected. If *every*
// registry failed, the search did not succeed — exit non-zero.
const allRegistriesFailed =
searchAllConfigured &&
results.errors?.length === registriesToSearch.length
if (opts.json) {
console.log(JSON.stringify(results, null, 2))
} else {
printSearchResults(results, {
query: options.query,
types: options.types,
registries: registriesToSearch,
})
}
process.exit(allRegistriesFailed ? 1 : 0)
} catch (error) {
handleError(error)
} finally {
clearRegistryContext()
}
})

View File

@@ -0,0 +1,78 @@
import path from "path"
import { getRegistryItems } from "@/src/registry/api"
import { configWithDefaults } from "@/src/registry/config"
import { clearRegistryContext } from "@/src/registry/context"
import { validateRegistryConfigForItems } from "@/src/registry/validator"
import { rawConfigSchema } from "@/src/schema"
import { loadEnvFiles } from "@/src/utils/env-loader"
import { getConfig } from "@/src/utils/get-config"
import { handleError } from "@/src/utils/handle-error"
import { ensureRegistriesInConfig } from "@/src/utils/registries"
import { Command } from "commander"
import fsExtra from "fs-extra"
import { z } from "zod"
const viewOptionsSchema = z.object({
cwd: z.string(),
})
export const view = new Command()
.name("view")
.description("view items from the registry")
.argument("<items...>", "item addresses to view")
.option(
"-c, --cwd <cwd>",
"the working directory. defaults to the current directory.",
process.cwd()
)
.action(async (items: string[], opts) => {
try {
const options = viewOptionsSchema.parse({
cwd: path.resolve(opts.cwd),
})
await loadEnvFiles(options.cwd)
// Start with a shadow config to support partial components.json.
let shadowConfig = configWithDefaults({})
// Check if there's a components.json file (partial or complete).
const componentsJsonPath = path.resolve(options.cwd, "components.json")
if (fsExtra.existsSync(componentsJsonPath)) {
const existingConfig = await fsExtra.readJson(componentsJsonPath)
const partialConfig = rawConfigSchema.partial().parse(existingConfig)
shadowConfig = configWithDefaults(partialConfig)
}
// Try to get the full config, but fall back to shadow config if it fails.
let config = shadowConfig
try {
const fullConfig = await getConfig(options.cwd)
if (fullConfig) {
config = configWithDefaults(fullConfig)
}
} catch {
// Use shadow config if getConfig fails (partial components.json).
}
const { config: updatedConfig, newRegistries } =
await ensureRegistriesInConfig(items, config, {
silent: true,
writeFile: false,
})
if (newRegistries.length > 0) {
config.registries = updatedConfig.registries
}
// Validate registries early for better error messages.
validateRegistryConfigForItems(items, config)
const payload = await getRegistryItems(items, { config })
console.log(JSON.stringify(payload, null, 2))
process.exit(0)
} catch (error) {
handleError(error)
} finally {
clearRegistryContext()
}
})

View File

@@ -0,0 +1,2 @@
export * from "./libraries"
export * from "./templates"

View File

@@ -0,0 +1,49 @@
export const iconLibraries = {
lucide: {
name: "lucide",
title: "Lucide",
packages: ["lucide-react"],
import: "import { ICON } from 'lucide-react'",
usage: "<ICON />",
export: "lucide-react",
},
tabler: {
name: "tabler",
title: "Tabler Icons",
packages: ["@tabler/icons-react"],
import: "import { ICON } from '@tabler/icons-react'",
usage: "<ICON />",
export: "@tabler/icons-react",
},
hugeicons: {
name: "hugeicons",
title: "HugeIcons",
packages: ["@hugeicons/react", "@hugeicons/core-free-icons"],
import:
"import { HugeiconsIcon } from '@hugeicons/react'\nimport { ICON } from '@hugeicons/core-free-icons';",
usage: "<HugeiconsIcon icon={ICON} strokeWidth={2} />",
export: "@hugeicons/core-free-icons",
},
phosphor: {
name: "phosphor",
title: "Phosphor Icons",
packages: ["@phosphor-icons/react"],
import: "import { ICON } from '@phosphor-icons/react'",
usage: "<ICON strokeWidth={2} />",
export: "@phosphor-icons/react",
},
remixicon: {
name: "remixicon",
title: "Remix Icon",
packages: ["@remixicon/react"],
import: "import { ICON } from '@remixicon/react'",
usage: "<ICON />",
export: "@remixicon/react",
},
} as const
export type IconLibraries = typeof iconLibraries
export type IconLibrary = IconLibraries[keyof IconLibraries]
export type IconLibraryName = keyof IconLibraries

View File

@@ -0,0 +1,128 @@
// Helpers for parsing icon library `import` and `usage` templates, e.g.
// import: "import { HugeiconsIcon } from '@hugeicons/react'\nimport { ICON } from '@hugeicons/core-free-icons';"
// usage: "<HugeiconsIcon icon={ICON} strokeWidth={2} />"
// ICON is the placeholder substituted with actual icon names.
// Anchored and free of overlapping quantifiers to avoid backtracking blowups.
const IMPORT_REGEX = /^import\s+{([^}]+)}\s+from\s+['"]([^'"]+)['"]/
export interface ParsedImport {
moduleSpecifier: string
namedImports: string[]
}
export interface ParsedUsage {
// The JSX tag icons render as. "ICON" means the icon itself is the tag
// (e.g. lucide); anything else is a wrapper component (e.g. HugeiconsIcon).
componentName: string
// Attribute carrying the icon when componentName is a wrapper (e.g. "icon").
iconAttribute?: string
// Remaining default attributes from the template, e.g. { strokeWidth: "{2}" }.
attributes: Record<string, string>
}
export function parseImportTemplate(template: string): ParsedImport[] {
const imports: ParsedImport[] = []
for (const line of template.split("\n")) {
const match = line.trim().match(IMPORT_REGEX)
if (!match) {
continue
}
imports.push({
moduleSpecifier: match[2],
namedImports: match[1].split(",").map((name) => name.trim()),
})
}
return imports
}
// The module specifier icons (ICON) are imported from.
export function getIconModuleSpecifier(template: string) {
return parseImportTemplate(template).find((parsed) =>
parsed.namedImports.includes("ICON")
)?.moduleSpecifier
}
// The usage template is parsed with a hand-rolled scanner instead of
// regexes: these helpers are part of the public API, so parsing must stay
// linear on arbitrary input (no backtracking).
export function parseUsageTemplate(usage: string): ParsedUsage {
const start = usage.indexOf("<")
const end = usage.indexOf("/>", start)
if (start === -1 || end === -1) {
return { componentName: "ICON", attributes: {} }
}
const inner = usage.slice(start + 1, end).trim()
const nameEnd = inner.search(/[^\w]/)
const componentName = nameEnd === -1 ? inner : inner.slice(0, nameEnd)
const rawAttributes = nameEnd === -1 ? "" : inner.slice(nameEnd)
if (!componentName) {
return { componentName: "ICON", attributes: {} }
}
const attributes: Record<string, string> = {}
let iconAttribute: string | undefined
for (const [name, value] of parseAttributeTokens(rawAttributes)) {
if (value === "{ICON}") {
iconAttribute = name
continue
}
attributes[name] = value
}
return { componentName, iconAttribute, attributes }
}
const NAME_CHAR = /[\w-]/
// Tokenizes `name={value}` / `name="value"` / `name='value'` pairs in a
// single linear pass.
function parseAttributeTokens(raw: string): Array<[string, string]> {
const tokens: Array<[string, string]> = []
let index = 0
while (index < raw.length) {
if (!NAME_CHAR.test(raw[index])) {
index++
continue
}
const nameStart = index
while (index < raw.length && NAME_CHAR.test(raw[index])) {
index++
}
const name = raw.slice(nameStart, index)
if (raw[index] !== "=") {
continue
}
index++
const open = raw[index]
const close = open === "{" ? "}" : open === '"' || open === "'" ? open : ""
if (!close) {
continue
}
const closeIndex = raw.indexOf(close, index + 1)
if (closeIndex === -1) {
break
}
tokens.push([name, raw.slice(index, closeIndex + 1)])
index = closeIndex + 1
}
return tokens
}

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env node
import { add } from "@/src/commands/add"
import { apply } from "@/src/commands/apply"
import { build } from "@/src/commands/build"
import { diff } from "@/src/commands/diff"
import { docs } from "@/src/commands/docs"
import { eject } from "@/src/commands/eject"
import { info } from "@/src/commands/info"
import { init } from "@/src/commands/init"
import { mcp } from "@/src/commands/mcp"
import { migrate } from "@/src/commands/migrate"
import { preset } from "@/src/commands/preset"
import { registry } from "@/src/commands/registry"
import { search } from "@/src/commands/search"
import { view } from "@/src/commands/view"
import { Command } from "commander"
import packageJson from "../package.json"
process.on("SIGINT", () => process.exit(0))
process.on("SIGTERM", () => process.exit(0))
async function main() {
const program = new Command()
.name("shadcn")
.description("build your component library")
.version(
packageJson.version || "1.0.0",
"-v, --version",
"display the version number"
)
program
.addCommand(init)
.addCommand(apply)
.addCommand(add)
.addCommand(diff)
.addCommand(docs)
.addCommand(view)
.addCommand(search)
.addCommand(migrate)
.addCommand(eject)
.addCommand(info)
.addCommand(build)
.addCommand(mcp)
.addCommand(preset)
.addCommand(registry)
program.parse()
}
main()
export * from "./registry/api"

View File

@@ -0,0 +1,598 @@
import { getRegistryItems, searchRegistries } from "@/src/registry"
import { withRegistryContext } from "@/src/registry/context"
import { RegistryError } from "@/src/registry/errors"
import {
resolveSearchRegistries,
SEARCHABLE_TYPES,
} from "@/src/registry/search"
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
import dedent from "dedent"
import { z } from "zod"
import { zodToJsonSchema } from "zod-to-json-schema"
import {
findUnknownTypesMessage,
formatItemExamples,
formatRegistryItems,
formatSearchResultsWithPagination,
formatSkippedRegistries,
getMcpConfig,
npxShadcn,
} from "./utils"
export const server = new Server(
{
name: "shadcn",
version: "1.0.0",
},
{
capabilities: {
logging: {},
resources: {},
tools: {},
},
}
)
// GitHub authentication notices must reach the MCP client before the first
// authenticated request. Stdout carries the protocol, so the console is not a
// usable surface here.
async function onGitHubAuthNotice(message: string) {
try {
await server.sendLoggingMessage({
level: "info",
data: message,
})
} catch {
console.error(message)
}
}
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "get_project_registries",
description:
"Get configured registry names from components.json - Returns error if no components.json exists (use init_project to create one)",
inputSchema: zodToJsonSchema(z.object({})),
},
{
name: "list_items_in_registries",
description:
"List items from registries (requires components.json - use init_project if missing)",
inputSchema: zodToJsonSchema(
z.object({
registries: z
.array(z.string())
.optional()
.describe(
"Array of registry names to list (e.g., ['@shadcn', '@acme']). Omit to list from every registry configured in components.json."
),
types: z
.array(z.string())
.optional()
.describe(
`Filter by item type. One of: ${SEARCHABLE_TYPES.join(", ")}.`
),
limit: z
.number()
.optional()
.describe(
"Maximum number of items to return (defaults to 100; use 0 for no limit)"
),
offset: z
.number()
.optional()
.describe("Number of items to skip for pagination"),
})
),
},
{
name: "search_items_in_registries",
description:
"Search for components in registries using fuzzy matching (requires components.json). After finding an item, use get_item_examples_from_registries to see usage examples.",
inputSchema: zodToJsonSchema(
z.object({
registries: z
.array(z.string())
.optional()
.describe(
"Array of registry names to search (e.g., ['@shadcn', '@acme']). Omit to search every registry configured in components.json."
),
query: z
.string()
.describe(
"Search query string for fuzzy matching against item names and descriptions"
),
types: z
.array(z.string())
.optional()
.describe(
`Filter by item type. One of: ${SEARCHABLE_TYPES.join(", ")}.`
),
limit: z
.number()
.optional()
.describe(
"Maximum number of items to return (defaults to 100; use 0 for no limit)"
),
offset: z
.number()
.optional()
.describe("Number of items to skip for pagination"),
})
),
},
{
name: "view_items_in_registries",
description:
"View detailed information about specific registry items including the name, description, type and files content. For usage examples, use get_item_examples_from_registries instead.",
inputSchema: zodToJsonSchema(
z.object({
items: z
.array(z.string())
.describe(
"Array of item names with registry prefix (e.g., ['@shadcn/button', '@shadcn/card'])"
),
})
),
},
{
name: "get_item_examples_from_registries",
description:
"Find usage examples and demos with their complete code. Search for patterns like 'accordion-demo', 'button example', 'card-demo', etc. Returns full implementation code with dependencies.",
inputSchema: zodToJsonSchema(
z.object({
registries: z
.array(z.string())
.optional()
.describe(
"Array of registry names to search (e.g., ['@shadcn', '@acme']). Omit to search every registry configured in components.json."
),
query: z
.string()
.describe(
"Search query for examples (e.g., 'accordion-demo', 'button demo', 'card example', 'tooltip-demo', 'example-booking-form', 'example-hero'). Common patterns: '{item-name}-demo', '{item-name} example', 'example {item-name}'"
),
})
),
},
{
name: "get_add_command_for_items",
description:
"Get the shadcn CLI add command for specific items in a registry. This is useful for adding one or more components to your project.",
inputSchema: zodToJsonSchema(
z.object({
items: z
.array(z.string())
.describe(
"Array of items to get the add command for prefixed with the registry name (e.g., ['@shadcn/button', '@shadcn/card'])"
),
})
),
},
{
name: "get_audit_checklist",
description:
"After creating new components or generating new code files, use this tool for a quick checklist to verify that everything is working as expected. Make sure to run the tool after all required steps have been completed.",
inputSchema: zodToJsonSchema(z.object({})),
},
],
}
})
server.setRequestHandler(CallToolRequestSchema, async (request) =>
withRegistryContext(() => handleCallTool(request), { onGitHubAuthNotice })
)
async function handleCallTool(request: {
params: { name: string; arguments?: Record<string, unknown> }
}) {
try {
if (!request.params.arguments) {
throw new Error("No tool arguments provided.")
}
switch (request.params.name) {
case "get_project_registries": {
const config = await getMcpConfig(process.cwd())
if (!config?.registries) {
return {
content: [
{
type: "text",
text: dedent`No components.json found or no registries configured.
To fix this:
1. Use the \`init\` command to create a components.json file
2. Or manually create components.json with a registries section`,
},
],
}
}
return {
content: [
{
type: "text",
text: dedent`The following registries are configured in the current project:
${Object.keys(config.registries)
.map((registry) => `- ${registry}`)
.join("\n")}
You can view the items in a registry by running:
\`${await npxShadcn("view @name-of-registry")}\`
For example: \`${await npxShadcn(
"view @shadcn"
)}\` or \`${await npxShadcn(
"view @shadcn @acme"
)}\` to view multiple registries.
`,
},
],
}
}
case "search_items_in_registries": {
const inputSchema = z.object({
registries: z.array(z.string()).optional(),
query: z.string(),
types: z.array(z.string()).optional(),
limit: z.number().optional(),
offset: z.number().optional(),
})
const args = inputSchema.parse(request.params.arguments)
const unknownTypesMessage = findUnknownTypesMessage(args.types)
if (unknownTypesMessage) {
return {
content: [{ type: "text", text: unknownTypesMessage }],
isError: true,
}
}
const config = await getMcpConfig(process.cwd())
// When registries are omitted, search every configured registry and
// tolerate individual failures.
const searchAll = !args.registries?.length
const registries = resolveSearchRegistries(
args.registries ?? [],
config
)
if (registries.length === 0) {
return {
content: [
{
type: "text",
text: dedent`No registries are configured. Add registries to components.json (use get_project_registries to inspect) or pass them explicitly.`,
},
],
}
}
const results = await searchRegistries(registries, {
query: args.query,
types: args.types,
limit: args.limit ?? 100,
offset: args.offset,
config,
useCache: false,
continueOnError: searchAll,
})
const skippedNote = formatSkippedRegistries(results)
if (results.items.length === 0) {
return {
content: [
{
type: "text",
text: dedent`No items found matching "${
args.query
}" in registries ${registries.join(
", "
)}, Try searching with a different query or registry.${skippedNote}`,
},
],
}
}
return {
content: [
{
type: "text",
text:
formatSearchResultsWithPagination(results, {
query: args.query,
registries,
}) + skippedNote,
},
],
}
}
case "list_items_in_registries": {
const inputSchema = z.object({
registries: z.array(z.string()).optional(),
types: z.array(z.string()).optional(),
limit: z.number().optional(),
offset: z.number().optional(),
cwd: z.string().optional(),
})
const args = inputSchema.parse(request.params.arguments)
const unknownTypesMessage = findUnknownTypesMessage(args.types)
if (unknownTypesMessage) {
return {
content: [{ type: "text", text: unknownTypesMessage }],
isError: true,
}
}
const config = await getMcpConfig(process.cwd())
const listAll = !args.registries?.length
const registries = resolveSearchRegistries(
args.registries ?? [],
config
)
if (registries.length === 0) {
return {
content: [
{
type: "text",
text: dedent`No registries are configured. Add registries to components.json (use get_project_registries to inspect) or pass them explicitly.`,
},
],
}
}
const results = await searchRegistries(registries, {
types: args.types,
limit: args.limit ?? 100,
offset: args.offset,
config,
useCache: false,
continueOnError: listAll,
})
const skippedNote = formatSkippedRegistries(results)
if (results.items.length === 0) {
return {
content: [
{
type: "text",
text: dedent`No items found in registries ${registries.join(
", "
)}.${skippedNote}`,
},
],
}
}
return {
content: [
{
type: "text",
text:
formatSearchResultsWithPagination(results, {
registries,
}) + skippedNote,
},
],
}
}
case "view_items_in_registries": {
const inputSchema = z.object({
items: z.array(z.string()),
})
const args = inputSchema.parse(request.params.arguments)
const registryItems = await getRegistryItems(args.items, {
config: await getMcpConfig(process.cwd()),
useCache: false,
})
if (registryItems?.length === 0) {
return {
content: [
{
type: "text",
text: dedent`No items found for: ${args.items.join(", ")}
Make sure the item names are correct and include the registry prefix (e.g., @shadcn/button).`,
},
],
}
}
const formattedItems = formatRegistryItems(registryItems)
return {
content: [
{
type: "text",
text: dedent`Item Details:
${formattedItems.join("\n\n---\n\n")}`,
},
],
}
}
case "get_item_examples_from_registries": {
const inputSchema = z.object({
query: z.string(),
registries: z.array(z.string()).optional(),
})
const args = inputSchema.parse(request.params.arguments)
const config = await getMcpConfig()
const searchAll = !args.registries?.length
const registries = resolveSearchRegistries(
args.registries ?? [],
config
)
if (registries.length === 0) {
return {
content: [
{
type: "text",
text: dedent`No registries are configured. Add registries to components.json (use get_project_registries to inspect) or pass them explicitly.`,
},
],
}
}
const results = await searchRegistries(registries, {
query: args.query,
config,
useCache: false,
continueOnError: searchAll,
})
if (results.items.length === 0) {
return {
content: [
{
type: "text",
text: dedent`No examples found for query "${args.query}".
Try searching with patterns like:
- "accordion-demo" for accordion examples
- "button demo" or "button example"
- Component name followed by "-demo" or "example"
You can also:
1. Use search_items_in_registries to find all items matching your query
2. View the main component with view_items_in_registries for inline usage documentation`,
},
],
}
}
const itemNames = results.items.map((item) => item.addCommandArgument)
const fullItems = await getRegistryItems(itemNames, {
config,
useCache: false,
})
return {
content: [
{
type: "text",
text: formatItemExamples(fullItems, args.query),
},
],
}
}
case "get_add_command_for_items": {
const args = z
.object({
items: z.array(z.string()),
})
.parse(request.params.arguments)
return {
content: [
{
type: "text",
text: await npxShadcn(`add ${args.items.join(" ")}`),
},
],
}
}
case "get_audit_checklist": {
return {
content: [
{
type: "text",
text: dedent`## Component Audit Checklist
After adding or generating components, check the following common issues:
- [ ] Ensure imports are correct i.e named vs default imports
- [ ] If using next/image, ensure images.remotePatterns next.config.js is configured correctly.
- [ ] Ensure all dependencies are installed.
- [ ] Check for linting errors or warnings
- [ ] Check for TypeScript errors
- [ ] Use the Playwright MCP if available.
`,
},
],
}
}
default:
throw new Error(`Tool ${request.params.name} not found`)
}
} catch (error) {
if (error instanceof z.ZodError) {
return {
content: [
{
type: "text",
text: dedent`Invalid input parameters:
${error.errors
.map((e) => `- ${e.path.join(".")}: ${e.message}`)
.join("\n")}
`,
},
],
isError: true,
}
}
if (error instanceof RegistryError) {
let errorMessage = error.message
if (error.suggestion) {
errorMessage += `\n\n💡 ${error.suggestion}`
}
if (error.context) {
errorMessage += `\n\nContext: ${JSON.stringify(error.context, null, 2)}`
}
return {
content: [
{
type: "text",
text: dedent`Error (${error.code}): ${errorMessage}`,
},
],
isError: true,
}
}
const errorMessage = error instanceof Error ? error.message : String(error)
return {
content: [
{
type: "text",
text: dedent`Error: ${errorMessage}`,
},
],
isError: true,
}
}
}

View File

@@ -0,0 +1,312 @@
import { SEARCHABLE_TYPES } from "@/src/registry/search"
import type { registryItemSchema, searchResultsSchema } from "@/src/schema"
import { describe, expect, it, vi } from "vitest"
import { z } from "zod"
import {
findUnknownTypesMessage,
formatItemExamples,
formatRegistryItems,
formatSearchResultsWithPagination,
formatSkippedRegistries,
} from "./utils"
vi.mock("@/src/utils/get-package-manager", () => ({
getPackageRunner: vi.fn().mockResolvedValue("npx"),
}))
type SearchResults = z.infer<typeof searchResultsSchema>
type RegistryItem = z.infer<typeof registryItemSchema>
const minimalResults: SearchResults = {
items: [
{
name: "button",
type: "registry:ui",
description: "A button.",
registry: "@shadcn",
addCommandArgument: "@shadcn/button",
},
],
pagination: { total: 1, offset: 0, limit: 10, hasMore: false },
}
describe("formatSearchResultsWithPagination", () => {
// KNOWN BUG (plans/005): npxShadcn is async but interpolated without await
// at src/mcp/utils.ts:49, so output contains "[object Promise]" instead of
// a runnable command. When someone fixes the bug, this it.fails test will
// start failing — flip it to a plain it() at that point.
it.fails("renders a runnable add command (currently broken)", () => {
const output = formatSearchResultsWithPagination(minimalResults)
expect(output).toContain(
"Add command: `npx shadcn@latest add @shadcn/button`"
)
})
it("pins the current broken output (contains [object Promise])", () => {
const output = formatSearchResultsWithPagination(minimalResults)
expect(output).toContain("Add command: `[object Promise]`")
})
it("includes a header without query or registries", () => {
const output = formatSearchResultsWithPagination(minimalResults)
expect(output).toContain("Found 1 items:")
})
it("includes the query in the header when provided", () => {
const output = formatSearchResultsWithPagination(minimalResults, {
query: "button",
})
expect(output).toContain('Found 1 items matching "button":')
})
it("includes registries in the header when provided", () => {
const output = formatSearchResultsWithPagination(minimalResults, {
registries: ["@shadcn", "@acme"],
})
expect(output).toContain("Found 1 items in registries @shadcn, @acme:")
})
it("includes both query and registries in the header when provided", () => {
const output = formatSearchResultsWithPagination(minimalResults, {
query: "button",
registries: ["@shadcn"],
})
expect(output).toContain(
'Found 1 items matching "button" in registries @shadcn:'
)
})
it("clamps the showing range to the total", () => {
const results: SearchResults = {
items: [],
pagination: { total: 25, offset: 20, limit: 10, hasMore: false },
}
const output = formatSearchResultsWithPagination(results)
expect(output).toContain("Showing items 21-25 of 25:")
})
it("shows the full range when there is no clamping needed", () => {
const output = formatSearchResultsWithPagination(minimalResults)
expect(output).toContain("Showing items 1-1 of 1:")
})
it("adds an offset hint when hasMore is true", () => {
const results: SearchResults = {
items: [],
pagination: { total: 25, offset: 0, limit: 10, hasMore: true },
}
const output = formatSearchResultsWithPagination(results)
expect(output).toContain(
"More items available. Use offset: 10 to see the next page."
)
})
it("does not add an offset hint when hasMore is false", () => {
const output = formatSearchResultsWithPagination(minimalResults)
expect(output).not.toContain("More items available")
})
it("includes type, description, and registry in item lines", () => {
const output = formatSearchResultsWithPagination(minimalResults)
expect(output).toContain("- button (registry:ui) - A button. [@shadcn]")
})
it("omits the registry bracket when registry is not present", () => {
const results: SearchResults = {
items: [
{
name: "button",
registry: "",
addCommandArgument: "@shadcn/button",
},
],
pagination: { total: 1, offset: 0, limit: 10, hasMore: false },
}
const output = formatSearchResultsWithPagination(results)
expect(output).not.toContain("[]")
expect(output).toContain("- button")
})
})
describe("findUnknownTypesMessage", () => {
it("returns null for undefined", () => {
expect(findUnknownTypesMessage(undefined)).toBeNull()
})
it("returns null for an empty array", () => {
expect(findUnknownTypesMessage([])).toBeNull()
})
it("returns null when all types are valid", () => {
expect(findUnknownTypesMessage(["ui", "hook"])).toBeNull()
})
it("returns a singular message for one unknown type", () => {
const message = findUnknownTypesMessage(["bogus"])
expect(message).toBe(
`Unknown type: bogus. Valid types: ${SEARCHABLE_TYPES.join(", ")}.`
)
})
it("returns a plural message for multiple unknown types", () => {
const message = findUnknownTypesMessage(["bogus", "fake"])
expect(message).toBe(
`Unknown types: bogus, fake. Valid types: ${SEARCHABLE_TYPES.join(", ")}.`
)
})
})
describe("formatSkippedRegistries", () => {
it("returns an empty string when there are no errors", () => {
expect(formatSkippedRegistries(minimalResults)).toBe("")
})
it("returns singular wording for one error", () => {
const results: SearchResults = {
...minimalResults,
errors: [{ registry: "@acme", message: "timed out" }],
}
const output = formatSkippedRegistries(results)
expect(output).toContain("Skipped 1 registry that failed to load:")
expect(output).toContain("- @acme: timed out")
})
it("returns plural wording with one line per error", () => {
const results: SearchResults = {
...minimalResults,
errors: [
{ registry: "@acme", message: "timed out" },
{ registry: "@other", message: "not found" },
],
}
const output = formatSkippedRegistries(results)
expect(output).toContain("Skipped 2 registries that failed to load:")
expect(output).toContain("- @acme: timed out")
expect(output).toContain("- @other: not found")
})
})
describe("formatRegistryItems", () => {
it("renders all lines for a full item", () => {
const items: RegistryItem[] = [
{
name: "button",
type: "registry:ui",
description: "A button.",
files: [{ path: "button.tsx", type: "registry:ui", target: "" }],
dependencies: ["radix-ui"],
devDependencies: ["typescript"],
} as RegistryItem,
]
const [output] = formatRegistryItems(items)
expect(output).toContain("## button")
expect(output).toContain("A button.")
expect(output).toContain("**Type:** registry:ui")
expect(output).toContain("**Files:** 1 file(s)")
expect(output).toContain("**Dependencies:** radix-ui")
expect(output).toContain("**Dev Dependencies:** typescript")
})
it("renders just the heading and type for a minimal item", () => {
const items: RegistryItem[] = [
{ name: "minimal", type: "registry:ui" } as RegistryItem,
]
const [output] = formatRegistryItems(items)
expect(output).toBe("## minimal\n**Type:** registry:ui")
})
it("produces no dependency/file lines for empty arrays", () => {
const items: RegistryItem[] = [
{
name: "empty",
type: "registry:ui",
files: [],
dependencies: [],
devDependencies: [],
} as RegistryItem,
]
const [output] = formatRegistryItems(items)
expect(output).toBe("## empty\n**Type:** registry:ui")
})
})
describe("formatItemExamples", () => {
it("includes a tsx fence for files with content", () => {
const items: RegistryItem[] = [
{
name: "button",
type: "registry:ui",
files: [
{
path: "button.tsx",
type: "registry:ui",
target: "",
content: "export const Button = () => null",
},
],
} as RegistryItem,
]
const output = formatItemExamples(items, "button")
expect(output).toContain("### Code (button.tsx):")
expect(output).toContain("```tsx")
expect(output).toContain("export const Button = () => null")
})
it("omits files without content", () => {
const items: RegistryItem[] = [
{
name: "button",
type: "registry:ui",
files: [{ path: "button.tsx", type: "registry:ui", target: "" }],
} as RegistryItem,
]
const output = formatItemExamples(items, "button")
expect(output).not.toContain("### Code")
expect(output).not.toContain("```tsx")
})
it("joins two items with a --- separator", () => {
const items: RegistryItem[] = [
{ name: "one", type: "registry:ui" } as RegistryItem,
{ name: "two", type: "registry:ui" } as RegistryItem,
]
const output = formatItemExamples(items, "q")
expect(output).toContain("## Example: one")
expect(output).toContain("\n\n---\n\n")
expect(output).toContain("## Example: two")
})
it("uses singular wording for one example", () => {
const items: RegistryItem[] = [
{ name: "one", type: "registry:ui" } as RegistryItem,
]
const output = formatItemExamples(items, "q")
expect(output).toContain('Found 1 example matching "q":')
})
it("uses plural wording for multiple examples", () => {
const items: RegistryItem[] = [
{ name: "one", type: "registry:ui" } as RegistryItem,
{ name: "two", type: "registry:ui" } as RegistryItem,
]
const output = formatItemExamples(items, "q")
expect(output).toContain('Found 2 examples matching "q":')
})
})

View File

@@ -0,0 +1,168 @@
import { getRegistriesConfig } from "@/src/registry/api"
import { findUnknownSearchTypes, SEARCHABLE_TYPES } from "@/src/registry/search"
import { registryItemSchema, searchResultsSchema } from "@/src/schema"
import { getPackageRunner } from "@/src/utils/get-package-manager"
import { z } from "zod"
const SHADCN_CLI_COMMAND = "shadcn@latest"
export async function npxShadcn(command: string) {
const packageRunner = await getPackageRunner(process.cwd())
return `${packageRunner} ${SHADCN_CLI_COMMAND} ${command}`
}
export async function getMcpConfig(cwd = process.cwd()) {
const config = await getRegistriesConfig(cwd, {
useCache: false,
})
return {
registries: config.registries,
}
}
export function formatSearchResultsWithPagination(
results: z.infer<typeof searchResultsSchema>,
options?: {
query?: string
registries?: string[]
}
) {
const { query, registries } = options || {}
const formattedItems = results.items.map((item) => {
const parts: string[] = [`- ${item.name}`]
if (item.type) {
parts.push(`(${item.type})`)
}
if (item.description) {
parts.push(`- ${item.description}`)
}
if (item.registry) {
parts.push(`[${item.registry}]`)
}
parts.push(
`\n Add command: \`${npxShadcn(`add ${item.addCommandArgument}`)}\``
)
return parts.join(" ")
})
let header = `Found ${results.pagination.total} items`
if (query) {
header += ` matching "${query}"`
}
if (registries && registries.length > 0) {
header += ` in registries ${registries.join(", ")}`
}
header += ":"
const showingRange = `Showing items ${
results.pagination.offset + 1
}-${Math.min(
results.pagination.offset + results.pagination.limit,
results.pagination.total
)} of ${results.pagination.total}:`
let output = `${header}\n\n${showingRange}\n\n${formattedItems.join("\n\n")}`
if (results.pagination.hasMore) {
output += `\n\nMore items available. Use offset: ${
results.pagination.offset + results.pagination.limit
} to see the next page.`
}
return output
}
// Validates type filters the same way the CLI does. Returns an error message
// listing the valid types when any are unknown, or null when all are valid.
export function findUnknownTypesMessage(types?: string[]): string | null {
if (!types?.length) {
return null
}
const unknown = findUnknownSearchTypes(types)
if (unknown.length === 0) {
return null
}
return `Unknown type${
unknown.length === 1 ? "" : "s"
}: ${unknown.join(", ")}. Valid types: ${SEARCHABLE_TYPES.join(", ")}.`
}
// When searching across all configured registries, some may fail to load.
// Returns a note listing them (empty string when there were no failures).
export function formatSkippedRegistries(
results: z.infer<typeof searchResultsSchema>
) {
if (!results.errors?.length) {
return ""
}
const lines = results.errors.map(
(error) => `- ${error.registry}: ${error.message}`
)
return `\n\nSkipped ${results.errors.length} registr${
results.errors.length === 1 ? "y" : "ies"
} that failed to load:\n${lines.join("\n")}`
}
export function formatRegistryItems(
items: z.infer<typeof registryItemSchema>[]
) {
return items.map((item) => {
const parts: string[] = [
`## ${item.name}`,
item.description ? `\n${item.description}\n` : "",
item.type ? `**Type:** ${item.type}` : "",
item.files && item.files.length > 0
? `**Files:** ${item.files.length} file(s)`
: "",
item.dependencies && item.dependencies.length > 0
? `**Dependencies:** ${item.dependencies.join(", ")}`
: "",
item.devDependencies && item.devDependencies.length > 0
? `**Dev Dependencies:** ${item.devDependencies.join(", ")}`
: "",
]
return parts.filter(Boolean).join("\n")
})
}
export function formatItemExamples(
items: z.infer<typeof registryItemSchema>[],
query: string
) {
const sections = items.map((item) => {
const parts: string[] = [
`## Example: ${item.name}`,
item.description ? `\n${item.description}\n` : "",
]
if (item.files?.length) {
item.files.forEach((file) => {
if (file.content) {
parts.push(`### Code (${file.path}):\n`)
parts.push("```tsx")
parts.push(file.content)
parts.push("```")
}
})
}
return parts.filter(Boolean).join("\n")
})
const header = `# Usage Examples\n\nFound ${items.length} example${
items.length > 1 ? "s" : ""
} matching "${query}":\n`
return header + sections.join("\n\n---\n\n")
}

View File

@@ -0,0 +1,321 @@
import {
Node,
SyntaxKind,
type ArrowFunction,
type CallExpression,
type FunctionDeclaration,
type FunctionExpression,
type Identifier,
type SourceFile,
type VariableStatement,
} from "ts-morph"
import type { Binding, OldPackage } from "./types"
export function collapseCanonicalWrappers(
sourceFile: SourceFile,
bindings: Binding[],
migratedPackages: Set<OldPackage>
) {
const clsxBindings = getClsxBindings(bindings).filter(
(binding) => binding.kind === "esm"
)
const twMergeBindings = getTwMergeBindings(bindings).filter(
(binding) => binding.kind === "esm"
)
let changes = 0
const candidates: Array<{
statement: FunctionDeclaration | VariableStatement
name: Identifier
expression: Node
restName: string
isInlineExport: boolean
isDefaultExport: boolean
}> = []
for (const declaration of sourceFile.getFunctions()) {
const name = declaration.getNameNode()
if (!name) {
continue
}
const data = getFunctionWrapperData(declaration)
if (data) {
candidates.push({
statement: declaration,
name,
...data,
isInlineExport:
declaration.isExported() && !declaration.isDefaultExport(),
isDefaultExport: declaration.isDefaultExport(),
})
}
}
for (const statement of sourceFile.getVariableStatements()) {
if (statement.getDeclarations().length !== 1) {
continue
}
const declaration = statement.getDeclarations()[0]
const name = declaration.getNameNode()
const initializer = declaration.getInitializer()
if (
!Node.isIdentifier(name) ||
(!Node.isArrowFunction(initializer) &&
!Node.isFunctionExpression(initializer))
) {
continue
}
const data = getFunctionWrapperData(initializer)
if (data) {
candidates.push({
statement,
name,
...data,
isInlineExport: statement.isExported(),
isDefaultExport: false,
})
}
}
for (const candidate of candidates) {
if (
!isCanonicalComposition(
candidate.expression,
candidate.restName,
clsxBindings,
twMergeBindings
)
) {
continue
}
const references = candidate.name.findReferencesAsNodes()
const exportSpecifiers = references
.map((reference) => reference.getParentIfKind(SyntaxKind.ExportSpecifier))
.filter((specifier) => specifier !== undefined)
if (references.length !== exportSpecifiers.length) {
continue
}
if (
!candidate.isInlineExport &&
!candidate.isDefaultExport &&
!exportSpecifiers.length
) {
continue
}
const exportedNames = new Set<string>()
if (candidate.isInlineExport) {
exportedNames.add(candidate.name.getText())
}
if (candidate.isDefaultExport) {
exportedNames.add("default")
}
for (const specifier of exportSpecifiers) {
exportedNames.add(
specifier.getAliasNode()?.getText() ?? specifier.getNameNode().getText()
)
const exportDeclaration = specifier.getExportDeclaration()
if (exportDeclaration.getNamedExports().length === 1) {
exportDeclaration.remove()
} else {
specifier.remove()
}
}
const statementIndex = sourceFile
.getStatements()
.findIndex((statement) => statement === candidate.statement)
candidate.statement.remove()
sourceFile.insertExportDeclaration(Math.max(statementIndex, 0), {
moduleSpecifier: "cn",
namedExports: Array.from(exportedNames).map((exportedName) => ({
name: "cn",
alias: exportedName === "cn" ? undefined : exportedName,
})),
})
migratedPackages.add("clsx")
migratedPackages.add("tailwind-merge")
changes++
}
return changes
}
function getFunctionWrapperData(
declaration: FunctionDeclaration | ArrowFunction | FunctionExpression
) {
const parameters = declaration.getParameters()
if (parameters.length !== 1 || !parameters[0].isRestParameter()) {
return null
}
const parameterName = parameters[0].getNameNode()
const body = declaration.getBody()
if (!Node.isIdentifier(parameterName) || !body) {
return null
}
if (!Node.isBlock(body)) {
return { expression: body, restName: parameterName.getText() }
}
const statements = body.getStatements()
if (statements.length !== 1 || !Node.isReturnStatement(statements[0])) {
return null
}
const expression = statements[0].getExpression()
return expression ? { expression, restName: parameterName.getText() } : null
}
function isCanonicalComposition(
node: Node,
restName: string,
clsxBindings: Binding[],
twMergeBindings: Binding[]
) {
if (!Node.isCallExpression(node) || !isBoundCall(node, twMergeBindings)) {
return false
}
const args = node.getArguments()
if (
args.length !== 1 ||
!Node.isCallExpression(args[0]) ||
!isBoundCall(args[0], clsxBindings)
) {
return false
}
const clsxArgs = args[0].getArguments()
if (clsxArgs.length !== 1) {
return false
}
const argument = clsxArgs[0]
if (Node.isIdentifier(argument)) {
return argument.getText() === restName
}
return (
Node.isSpreadElement(argument) &&
Node.isIdentifier(argument.getExpression()) &&
argument.getExpression().getText() === restName
)
}
export function findCompositions(sourceFile: SourceFile, bindings: Binding[]) {
const clsxBindings = getClsxBindings(bindings)
const twMergeBindings = getTwMergeBindings(bindings)
const compositions: Array<{
outerCall: CallExpression
argumentsText: string
kind: Binding["kind"]
}> = []
for (const call of sourceFile.getDescendantsOfKind(
SyntaxKind.CallExpression
)) {
const twMergeBinding = findBindingForCall(call, twMergeBindings)
if (!twMergeBinding) {
continue
}
let hasClsxCall = false
const args = call.getArguments().flatMap((argument) => {
const clsxBinding = findBindingForCall(argument, clsxBindings)
if (!clsxBinding || clsxBinding.kind !== twMergeBinding.kind) {
return [argument.getText()]
}
hasClsxCall = true
const innerArguments = formatCnArguments(argument)
return innerArguments ? [innerArguments] : []
})
if (hasClsxCall) {
compositions.push({
outerCall: call,
argumentsText: args.join(", "),
kind: twMergeBinding.kind,
})
}
}
return compositions
}
function getClsxBindings(bindings: Binding[]) {
return bindings.filter(
(binding) =>
binding.module.startsWith("clsx") &&
(binding.importedName === "default" || binding.importedName === "clsx")
)
}
function getTwMergeBindings(bindings: Binding[]) {
return bindings.filter(
(binding) =>
binding.module === "tailwind-merge" && binding.importedName === "twMerge"
)
}
function isBoundCall(call: Node, bindings: Binding[]) {
return findBindingForCall(call, bindings) !== null
}
function findBindingForCall(call: Node, bindings: Binding[]) {
if (!Node.isCallExpression(call)) {
return null
}
const expression = call.getExpression()
if (!Node.isIdentifier(expression)) {
return null
}
return (
bindings.find(
(binding) =>
binding.localName === expression.getText() &&
binding.identifier
.findReferencesAsNodes()
.some((reference) => reference.getStart() === expression.getStart())
) ?? null
)
}
function formatCnArguments(call: Node) {
if (!Node.isCallExpression(call)) {
return ""
}
return call
.getArguments()
.map((argument) => {
if (!Node.isIdentifier(argument)) {
return argument.getText()
}
const functionLike = argument.getFirstAncestor((ancestor) =>
Node.isFunctionLikeDeclaration(ancestor)
)
const restParameter = functionLike
?.getParameters()
.find(
(parameter) =>
parameter.isRestParameter() &&
parameter.getNameNode().getText() === argument.getText()
)
return restParameter ? `...${argument.getText()}` : argument.getText()
})
.join(", ")
}

View File

@@ -0,0 +1,51 @@
import { Node, SyntaxKind, type SourceFile } from "ts-morph"
import type { OldPackage } from "./types"
export function migrateCnfastSpecifiers(
sourceFile: SourceFile,
migratedPackages: Set<OldPackage>
) {
let changes = 0
for (const declaration of sourceFile.getImportDeclarations()) {
if (declaration.getModuleSpecifierValue() === "cnfast") {
declaration.setModuleSpecifier("cn")
changes++
}
}
for (const declaration of sourceFile.getExportDeclarations()) {
if (declaration.getModuleSpecifierValue() === "cnfast") {
declaration.setModuleSpecifier("cn")
changes++
}
}
for (const call of sourceFile.getDescendantsOfKind(
SyntaxKind.CallExpression
)) {
const expression = call.getExpression()
if (
expression.getKind() !== SyntaxKind.ImportKeyword &&
(!Node.isIdentifier(expression) || expression.getText() !== "require")
) {
continue
}
const argument = call.getArguments()[0]
if (
Node.isStringLiteral(argument) &&
argument.getLiteralValue() === "cnfast"
) {
argument.replaceWithText('"cn"')
changes++
}
}
if (changes) {
migratedPackages.add("cnfast")
}
return changes
}

View File

@@ -0,0 +1,108 @@
import { Node, type SourceFile } from "ts-morph"
import { getRequiredPackage } from "./references"
import type { Binding, OldModule } from "./types"
export function collectBindings(sourceFile: SourceFile) {
return [
...collectEsmBindings(sourceFile),
...collectCommonJsBindings(sourceFile),
]
}
function collectEsmBindings(sourceFile: SourceFile) {
const bindings: Binding[] = []
for (const declaration of sourceFile.getImportDeclarations()) {
const module = declaration.getModuleSpecifierValue()
if (!isOldModule(module)) {
continue
}
const defaultImport = declaration.getDefaultImport()
if (defaultImport && module.startsWith("clsx")) {
bindings.push({
identifier: defaultImport,
localName: defaultImport.getText(),
importedName: "default",
module,
kind: "esm",
})
}
for (const specifier of declaration.getNamedImports()) {
if (declaration.isTypeOnly() || specifier.isTypeOnly()) {
continue
}
const identifier = specifier.getAliasNode() ?? specifier.getNameNode()
if (!Node.isIdentifier(identifier)) {
continue
}
bindings.push({
identifier,
localName: identifier.getText(),
importedName: specifier.getName(),
module,
kind: "esm",
})
}
}
return bindings
}
function collectCommonJsBindings(sourceFile: SourceFile) {
const bindings: Binding[] = []
for (const declaration of sourceFile.getVariableDeclarations()) {
const initializer = declaration.getInitializer()
if (!initializer || !Node.isCallExpression(initializer)) {
continue
}
const module = getRequiredPackage(initializer)
if (!module) {
continue
}
const nameNode = declaration.getNameNode()
if (Node.isIdentifier(nameNode) && module.startsWith("clsx")) {
bindings.push({
identifier: nameNode,
localName: nameNode.getText(),
importedName: "default",
module,
kind: "cjs",
})
continue
}
if (!Node.isObjectBindingPattern(nameNode)) {
continue
}
for (const element of nameNode.getElements()) {
const identifier = element.getNameNode()
if (!Node.isIdentifier(identifier)) {
continue
}
bindings.push({
identifier,
localName: identifier.getText(),
importedName:
element.getPropertyNameNode()?.getText() ?? identifier.getText(),
module,
kind: "cjs",
})
}
}
return bindings
}
function isOldModule(value: string): value is OldModule {
return value === "clsx" || value === "clsx/lite" || value === "tailwind-merge"
}

View File

@@ -0,0 +1,80 @@
import { Node, type SourceFile } from "ts-morph"
import type { PendingImport } from "./types"
export function addCommonJsCnRequire(
sourceFile: SourceFile,
localName: string
) {
const binding = localName === "cn" ? "cn" : `cn: ${localName}`
const statements = sourceFile.getStatements()
let index = 0
while (index < statements.length) {
const statement = statements[index]
if (
Node.isExpressionStatement(statement) &&
Node.isStringLiteral(statement.getExpression())
) {
index++
continue
}
break
}
sourceFile.insertStatements(index, `const { ${binding} } = require("cn")`)
}
export function addPendingImports(
sourceFile: SourceFile,
pendingImports: PendingImport[]
) {
for (const pendingImport of dedupePendingImports(pendingImports)) {
let declaration = sourceFile
.getImportDeclarations()
.find(
(candidate) =>
candidate.getModuleSpecifierValue() ===
pendingImport.moduleSpecifier &&
!candidate.getNamespaceImport() &&
!candidate.isTypeOnly()
)
declaration ??= sourceFile.addImportDeclaration({
moduleSpecifier: pendingImport.moduleSpecifier,
})
const exists = declaration.getNamedImports().some((specifier) => {
const localName =
specifier.getAliasNode()?.getText() ?? specifier.getNameNode().getText()
return (
specifier.getName() === pendingImport.importedName &&
localName === pendingImport.localName
)
})
if (!exists) {
declaration.addNamedImport({
name: pendingImport.importedName,
alias:
pendingImport.importedName === pendingImport.localName
? undefined
: pendingImport.localName,
isTypeOnly: pendingImport.isTypeOnly,
})
}
}
}
function dedupePendingImports(imports: PendingImport[]) {
return imports.filter(
(item, index) =>
imports.findIndex(
(candidate) =>
candidate.moduleSpecifier === item.moduleSpecifier &&
candidate.importedName === item.importedName &&
candidate.localName === item.localName &&
candidate.isTypeOnly === item.isTypeOnly
) === index
)
}

View File

@@ -0,0 +1,126 @@
import { promises as fs } from "fs"
import path from "path"
import fg from "fast-glob"
import { ScriptKind } from "ts-morph"
import type { SourceRegion } from "./types"
const SOURCE_PATTERN = "**/*.{js,jsx,ts,tsx,mjs,mts,cjs,cts,vue,svelte,astro}"
const SOURCE_IGNORE = [
"**/node_modules/**",
"**/.git/**",
"**/.next/**",
"**/.nuxt/**",
"**/.svelte-kit/**",
"**/.astro/**",
"**/.output/**",
"**/.vercel/**",
"**/dist/**",
"**/build/**",
"**/out/**",
"**/coverage/**",
]
export async function resolveMigrationFiles(cwd: string, migratePath?: string) {
if (!migratePath) {
return findSourceFiles(cwd)
}
if (migratePath.includes("*")) {
const files = await findSourceFiles(cwd, migratePath)
if (!files.length) {
throw new Error(`No files found matching: ${migratePath}`)
}
return files
}
const resolvedPath = path.resolve(cwd, migratePath)
const stat = await fs.stat(resolvedPath).catch(() => null)
if (!stat) {
throw new Error(`File not found: ${migratePath}`)
}
if (stat.isFile()) {
return [resolvedPath]
}
if (stat.isDirectory()) {
const files = await findSourceFiles(resolvedPath)
if (!files.length) {
throw new Error(`No files found matching: ${migratePath}`)
}
return files
}
throw new Error(`Unsupported path type: ${migratePath}`)
}
function findSourceFiles(cwd: string, pattern = SOURCE_PATTERN) {
return fg(pattern, {
cwd,
absolute: true,
onlyFiles: true,
ignore: SOURCE_IGNORE,
suppressErrors: true,
dot: true,
})
}
export function getScriptRegions(content: string, filename: string) {
const regions: SourceRegion[] = []
const pattern = /<script\b([^>]*)>([\s\S]*?)<\/script(?:\s[^>]*)?>/gi
let match: RegExpExecArray | null
while ((match = pattern.exec(content)) !== null) {
const attributes = match[1]
const source = match[2]
const offset = match[0].indexOf(source)
const language = attributes.match(/\blang=["'](js|jsx|ts|tsx)["']/i)?.[1]
regions.push({
start: match.index + offset,
end: match.index + offset + source.length,
filename: `${filename}.${regions.length}.${language?.toLowerCase() ?? "js"}`,
})
}
return regions
}
export function getAstroFrontmatterRegions(content: string, filename: string) {
const match = content.match(/^(?:\uFEFF)?---[\t ]*\r?\n([\s\S]*?)\r?\n---/)
if (!match || match.index === undefined) {
return []
}
const source = match[1]
const offset = match[0].indexOf(source)
return [
{
start: match.index + offset,
end: match.index + offset + source.length,
filename: `${filename}.frontmatter.ts`,
},
]
}
export function normalizeVirtualFilename(filename: string) {
const basename = path.basename(filename).replace(/[^a-zA-Z0-9_.-]/g, "-")
return basename.match(/\.(?:js|jsx|ts|tsx|mjs|mts|cjs|cts)$/)
? `/${basename}`
: `/${basename}.tsx`
}
export function getScriptKind(filename: string) {
switch (path.extname(filename).toLowerCase()) {
case ".ts":
case ".mts":
case ".cts":
return ScriptKind.TS
case ".tsx":
return ScriptKind.TSX
case ".jsx":
return ScriptKind.JSX
default:
return ScriptKind.JS
}
}

View File

@@ -0,0 +1,187 @@
import { promises as fs } from "fs"
import path from "path"
import { getPackageInfo } from "@/src/utils/get-package-info"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import {
installDependencies,
removeDependencies,
} from "@/src/utils/updaters/update-dependencies"
import fsExtra from "fs-extra"
import prompts from "prompts"
import { resolveMigrationFiles } from "./files"
import { createCnTransformer } from "./transform"
import { OLD_PACKAGES, type CnMigrationIssue, type OldPackage } from "./types"
export { transformCnSource } from "./transform"
export type { CnMigrationIssue, CnSourceTransformResult } from "./types"
export async function migrateCn(options: {
cwd: string
path?: string
yes?: boolean
}) {
if (!fsExtra.existsSync(path.resolve(options.cwd, "package.json"))) {
throw new Error(
"No `package.json` file found. Ensure you are at the root of your project."
)
}
const files = await resolveMigrationFiles(options.cwd, options.path)
const contents = await Promise.all(
files.map((file) => fs.readFile(file, "utf-8"))
)
const transform = createCnTransformer()
const results = files.map((file, index) => {
const content = contents[index]
return { file, content, result: transform(content, file) }
})
const changedFiles = results.filter(
({ content, result }) => content !== result.content
)
const unsupported = results.flatMap(({ file, result }) =>
result.unsupported.map((issue) => ({ ...issue, file }))
)
const migratedPackages = new Set(
results.flatMap(({ result }) => result.migratedPackages)
)
if (!changedFiles.length) {
logger.info("No supported clsx, tailwind-merge or cnfast usage found.")
printUnsupportedIssues(unsupported, options.cwd)
return
}
assertTailwindCompatibility(options.cwd, migratedPackages)
if (!options.yes) {
const { confirm } = await prompts({
type: "confirm",
name: "confirm",
initial: true,
message: `We will migrate ${highlighter.info(
changedFiles.length
)} file(s) to ${highlighter.info("cn")}${
unsupported.length
? ` and leave ${highlighter.warn(
unsupported.length
)} item(s) for manual review`
: ""
}. Continue?`,
})
if (!confirm) {
logger.info("Migration cancelled.")
return
}
}
const migrationSpinner = spinner("Migrating to cn...")?.start()
await installDependencies(options.cwd, ["cn"])
for (const { file, result } of changedFiles) {
await fs.writeFile(file, result.content)
}
migrationSpinner?.succeed(
`Migrated ${changedFiles.length} file${changedFiles.length === 1 ? "" : "s"}.`
)
const retainedPackages = new Set(
results.flatMap(({ result }) => result.remainingPackages)
)
const removablePackages = options.path
? []
: OLD_PACKAGES.filter(
(packageName) =>
migratedPackages.has(packageName) &&
!retainedPackages.has(packageName)
)
if (removablePackages.length) {
try {
await removeDependencies(options.cwd, removablePackages)
} catch {
logger.warn(
`Could not remove ${removablePackages.join(
", "
)}. Remove them manually after reviewing the migration.`
)
}
}
if (options.path) {
logger.info(
"Kept clsx, tailwind-merge and cnfast dependencies because this was a scoped migration."
)
}
printUnsupportedIssues(unsupported, options.cwd)
}
function assertTailwindCompatibility(
cwd: string,
migratedPackages: Set<OldPackage>
) {
if (!migratedPackages.has("tailwind-merge")) {
return
}
const packageInfo = getPackageInfo(cwd, false)
const tailwindVersion = findDependencyVersion(packageInfo, "tailwindcss")
const tailwindMergeVersion = findDependencyVersion(
packageInfo,
"tailwind-merge"
)
if (
(getDeclaredMajor(tailwindVersion) ?? 4) < 4 ||
(getDeclaredMajor(tailwindMergeVersion) ?? 3) < 3
) {
throw new Error(
"The cn migration requires Tailwind CSS v4 and tailwind-merge v3. Tailwind CSS v3 projects should continue using tailwind-merge v2."
)
}
}
function findDependencyVersion(
packageInfo: ReturnType<typeof getPackageInfo>,
packageName: string
) {
return (
packageInfo?.dependencies?.[packageName] ??
packageInfo?.devDependencies?.[packageName] ??
packageInfo?.optionalDependencies?.[packageName] ??
packageInfo?.peerDependencies?.[packageName]
)
}
function getDeclaredMajor(version?: string) {
if (!version || /^(?:workspace|file|link|git|https?):/i.test(version)) {
return null
}
const match = version.match(/(?:^|[^0-9])(\d+)(?:\.|$)/)
return match ? Number(match[1]) : null
}
function printUnsupportedIssues(issues: CnMigrationIssue[], cwd: string) {
if (!issues.length) {
return
}
logger.break()
logger.warn(
`Manual review required for ${issues.length} item${
issues.length === 1 ? "" : "s"
}:`
)
for (const issue of issues) {
const file = issue.file ? path.relative(cwd, issue.file) : "unknown file"
const symbol = issue.symbol ? ` (${issue.symbol})` : ""
logger.warn(` - ${file}${symbol}: ${issue.reason}`)
}
}

View File

@@ -0,0 +1,101 @@
import { Node, SyntaxKind, type SourceFile } from "ts-morph"
import {
OLD_PACKAGES,
type CnMigrationIssue,
type OldModule,
type OldPackage,
} from "./types"
export function reportUnsupportedReferences(
sourceFile: SourceFile,
unsupported: CnMigrationIssue[]
) {
reportDynamicImports(sourceFile, unsupported)
reportExportDeclarations(sourceFile, unsupported)
}
function reportDynamicImports(
sourceFile: SourceFile,
unsupported: CnMigrationIssue[]
) {
for (const call of sourceFile.getDescendantsOfKind(
SyntaxKind.CallExpression
)) {
if (call.getExpression().getKind() !== SyntaxKind.ImportKeyword) {
continue
}
const argument = call.getArguments()[0]
if (!argument || !Node.isStringLiteral(argument)) {
continue
}
const moduleSpecifier = argument.getLiteralValue()
if (isOldModule(moduleSpecifier)) {
unsupported.push({
packageName: getOldPackage(moduleSpecifier),
reason: "dynamic imports require manual migration",
})
}
}
}
function reportExportDeclarations(
sourceFile: SourceFile,
unsupported: CnMigrationIssue[]
) {
for (const declaration of sourceFile.getExportDeclarations()) {
const moduleSpecifier = declaration.getModuleSpecifierValue()
if (!moduleSpecifier || !isOldModule(moduleSpecifier)) {
continue
}
const packageName = getOldPackage(moduleSpecifier)
const namedExports = declaration.getNamedExports()
if (!namedExports.length) {
unsupported.push({
packageName,
reason: "export-from declarations require manual migration",
})
continue
}
for (const specifier of namedExports) {
unsupported.push({
packageName,
symbol: specifier.getName(),
reason: "export-from declarations require manual migration",
})
}
}
}
export function getRemainingPackages(content: string) {
return OLD_PACKAGES.filter((packageName) => {
const escaped = packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
return new RegExp(
`(?:from\\s*|import\\s*\\(|require\\s*\\()\\s*["']${escaped}(?:/lite)?["']`
).test(content)
})
}
export function dedupeIssues(issues: CnMigrationIssue[]) {
return issues.filter(
(issue, index) =>
issues.findIndex(
(candidate) =>
candidate.packageName === issue.packageName &&
candidate.symbol === issue.symbol &&
candidate.reason === issue.reason
) === index
)
}
function getOldPackage(module: OldModule): OldPackage {
return module.startsWith("clsx") ? "clsx" : "tailwind-merge"
}
function isOldModule(value: string): value is OldModule {
return value === "clsx" || value === "clsx/lite" || value === "tailwind-merge"
}

View File

@@ -0,0 +1,127 @@
import { Node, SyntaxKind, type Identifier, type SourceFile } from "ts-morph"
import type { OldModule } from "./types"
export function getLiveReferences(identifier: Identifier) {
const symbol = identifier.getSymbol()?.compilerSymbol
const candidates = identifier
.getSourceFile()
.getDescendantsOfKind(SyntaxKind.Identifier)
.filter((candidate) => candidate !== identifier)
const references = candidates.filter(
(candidate) => symbol && candidate.getSymbol()?.compilerSymbol === symbol
)
for (const reference of identifier.findReferencesAsNodes()) {
const candidate = candidates.find(
(current) =>
current.getStart() === reference.getStart() &&
current.getText() === reference.getText()
)
if (candidate && !references.includes(candidate)) {
references.push(candidate)
}
}
return references
}
export function getUnsupportedBindingReason(
identifier: Identifier,
importedName: string
) {
const references = getLiveReferences(identifier)
if (importedName === "validators") {
const hasUnsupportedUsage = references.some((reference) => {
const access = reference.getParentIfKind(
SyntaxKind.PropertyAccessExpression
)
if (access) {
const call = access.getParentIfKind(SyntaxKind.CallExpression)
if (
call !== undefined &&
call.getExpression().getStart() === access.getStart()
) {
return true
}
}
const declaration = reference.getParentIfKind(
SyntaxKind.VariableDeclaration
)
return (
declaration !== undefined &&
Node.isObjectBindingPattern(declaration.getNameNode()) &&
declaration.getInitializer()?.getStart() === reference.getStart()
)
})
if (hasUnsupportedUsage) {
return "callable validators are not supported by cn"
}
}
if (importedName === "createTailwindMerge") {
const hasVariadicCall = references.some((reference) => {
const call = reference.getParentIfKind(SyntaxKind.CallExpression)
return (
call !== undefined &&
call.getExpression().getStart() === reference.getStart() &&
call.getArguments().length > 1
)
})
if (hasVariadicCall) {
return "variadic createTailwindMerge calls are not supported by cn"
}
}
return null
}
export function getAvailableLocalName(
sourceFile: SourceFile,
preferred: string,
fallback: string
) {
const names = new Set(
sourceFile
.getDescendantsOfKind(SyntaxKind.Identifier)
.map((identifier) => identifier.getText())
)
if (!names.has(preferred)) {
return preferred
}
if (!names.has(fallback)) {
return fallback
}
let index = 2
while (names.has(`${fallback}${index}`)) {
index++
}
return `${fallback}${index}`
}
export function getRequiredPackage(call: Node) {
if (
!Node.isCallExpression(call) ||
call.getExpression().getText() !== "require"
) {
return null
}
const args = call.getArguments()
if (args.length !== 1 || !Node.isStringLiteral(args[0])) {
return null
}
const value = args[0].getLiteralValue()
return isOldModule(value) ? value : null
}
function isOldModule(value: string): value is OldModule {
return value === "clsx" || value === "clsx/lite" || value === "tailwind-merge"
}

View File

@@ -0,0 +1,358 @@
import {
Node,
type Identifier,
type SourceFile,
type VariableDeclaration,
} from "ts-morph"
import { addPendingImports } from "./emit"
import { reportUnsupportedReferences } from "./issues"
import {
getLiveReferences,
getRequiredPackage,
getUnsupportedBindingReason,
} from "./references"
import {
TAILWIND_CONFIG_EXPORTS,
TAILWIND_ROOT_EXPORTS,
type CnMigrationIssue,
type OldModule,
type OldPackage,
type PendingImport,
} from "./types"
export function migrateSpecifiers(
sourceFile: SourceFile,
pendingImports: PendingImport[],
unsupported: CnMigrationIssue[],
migratedPackages: Set<OldPackage>,
externalContent: string
) {
const changes =
migrateEsmImports(
sourceFile,
pendingImports,
unsupported,
migratedPackages,
externalContent
) + migrateCommonJsRequires(sourceFile, unsupported, migratedPackages)
reportUnsupportedReferences(sourceFile, unsupported)
addPendingImports(sourceFile, pendingImports)
return changes
}
function migrateEsmImports(
sourceFile: SourceFile,
pendingImports: PendingImport[],
unsupported: CnMigrationIssue[],
migratedPackages: Set<OldPackage>,
externalContent: string
) {
let changes = 0
for (const declaration of [...sourceFile.getImportDeclarations()]) {
const moduleSpecifier = declaration.getModuleSpecifierValue()
if (!isOldModule(moduleSpecifier)) {
continue
}
const packageName = getOldPackage(moduleSpecifier)
const hadImportClause = Boolean(declaration.getImportClause())
const namespaceImport = declaration.getNamespaceImport()
if (namespaceImport) {
unsupported.push({
packageName,
symbol: namespaceImport.getText(),
reason: "namespace imports cannot be migrated safely",
})
}
const defaultImport = declaration.getDefaultImport()
if (defaultImport) {
if (packageName === "clsx") {
if (hasReferences(defaultImport, externalContent)) {
pendingImports.push({
moduleSpecifier: moduleSpecifier === "clsx/lite" ? "cn/lite" : "cn",
importedName: "clsx",
localName: defaultImport.getText(),
isTypeOnly: false,
})
}
declaration.removeDefaultImport()
migratedPackages.add("clsx")
changes++
} else {
unsupported.push({
packageName,
symbol: defaultImport.getText(),
reason: "tailwind-merge has no compatible default import mapping",
})
}
}
for (const specifier of [...declaration.getNamedImports()]) {
const importedName = specifier.getName()
const localName =
specifier.getAliasNode()?.getText() ?? specifier.getNameNode().getText()
const isTypeOnly = declaration.isTypeOnly() || specifier.isTypeOnly()
const target = getImportTarget(moduleSpecifier, importedName, isTypeOnly)
if (!target) {
unsupported.push({
packageName,
symbol: importedName,
reason:
importedName.startsWith("Experimental") ||
importedName === "experimentalParseClassName"
? "experimentalParseClassName is not supported by cn"
: "no compatible cn export is available",
})
continue
}
const identifier = specifier.getAliasNode() ?? specifier.getNameNode()
if (!Node.isIdentifier(identifier)) {
unsupported.push({
packageName,
symbol: importedName,
reason: "string-named imports cannot be migrated safely",
})
continue
}
const unsupportedReason = getUnsupportedBindingReason(
identifier,
importedName
)
if (unsupportedReason) {
unsupported.push({
packageName,
symbol: importedName,
reason: unsupportedReason,
})
continue
}
if (hasReferences(identifier, externalContent)) {
pendingImports.push({ ...target, localName, isTypeOnly })
}
specifier.remove()
migratedPackages.add(packageName)
changes++
}
if (
hadImportClause &&
!declaration.getDefaultImport() &&
!declaration.getNamespaceImport() &&
declaration.getNamedImports().length === 0
) {
declaration.remove()
} else if (!hadImportClause) {
unsupported.push({
packageName,
reason: "side-effect imports are left unchanged",
})
}
}
return changes
}
function getImportTarget(
moduleSpecifier: OldModule,
importedName: string,
isTypeOnly: boolean
): Omit<PendingImport, "localName" | "isTypeOnly"> | null {
if (moduleSpecifier === "clsx") {
return {
moduleSpecifier: "cn",
importedName: importedName === "default" ? "clsx" : importedName,
}
}
if (moduleSpecifier === "clsx/lite") {
if (
(importedName === "clsx" || importedName === "default") &&
!isTypeOnly
) {
return { moduleSpecifier: "cn/lite", importedName: "clsx" }
}
if (
["ClassValue", "ClassArray", "ClassDictionary"].includes(importedName)
) {
return { moduleSpecifier: "cn", importedName }
}
return null
}
if (TAILWIND_ROOT_EXPORTS.has(importedName)) {
return { moduleSpecifier: "cn", importedName }
}
const configExport = TAILWIND_CONFIG_EXPORTS.get(importedName)
return configExport
? { moduleSpecifier: "cn/config", importedName: configExport }
: null
}
function migrateCommonJsRequires(
sourceFile: SourceFile,
unsupported: CnMigrationIssue[],
migratedPackages: Set<OldPackage>
) {
let changes = 0
for (const declaration of sourceFile.getVariableDeclarations()) {
const initializer = declaration.getInitializer()
if (!initializer || !Node.isCallExpression(initializer)) {
continue
}
const requiredPackage = getRequiredPackage(initializer)
if (!requiredPackage) {
continue
}
const packageName = getOldPackage(requiredPackage)
const nameNode = declaration.getNameNode()
if (Node.isIdentifier(nameNode) && requiredPackage.startsWith("clsx")) {
if (!getLiveReferences(nameNode).length) {
removeVariableDeclaration(declaration)
} else {
initializer.replaceWithText(
`require(${JSON.stringify(
requiredPackage === "clsx/lite" ? "cn/lite" : "cn"
)}).clsx`
)
}
migratedPackages.add("clsx")
changes++
continue
}
if (!Node.isObjectBindingPattern(nameNode)) {
unsupported.push({
packageName,
reason: "this CommonJS require shape cannot be migrated safely",
})
continue
}
const elements = nameNode.getElements()
const unsafeElement = elements.find((element) => {
const identifier = element.getNameNode()
const importedName =
element.getPropertyNameNode()?.getText() ?? identifier.getText()
return (
Node.isIdentifier(identifier) &&
getUnsupportedBindingReason(identifier, importedName)
)
})
if (unsafeElement) {
const identifier = unsafeElement.getNameNode() as Identifier
const importedName =
unsafeElement.getPropertyNameNode()?.getText() ?? identifier.getText()
unsupported.push({
packageName,
symbol: importedName,
reason: getUnsupportedBindingReason(identifier, importedName)!,
})
continue
}
if (
elements.every((element) => {
const identifier = element.getNameNode()
return (
Node.isIdentifier(identifier) && !getLiveReferences(identifier).length
)
})
) {
removeVariableDeclaration(declaration)
migratedPackages.add(packageName)
changes++
continue
}
const importedNames = elements.map(
(element) =>
element.getPropertyNameNode()?.getText() ??
element.getNameNode().getText()
)
const targetModules = new Set(
importedNames.map(
(importedName) =>
getImportTarget(requiredPackage, importedName, false)
?.moduleSpecifier ?? null
)
)
if (targetModules.size !== 1 || targetModules.has(null)) {
unsupported.push({
packageName,
reason:
"mixed or unsupported CommonJS exports require manual migration",
})
continue
}
initializer
.getArguments()[0]
.replaceWithText(JSON.stringify(Array.from(targetModules)[0]))
for (const element of elements) {
const importedName =
element.getPropertyNameNode()?.getText() ??
element.getNameNode().getText()
const target = getImportTarget(requiredPackage, importedName, false)!
if (target.importedName !== importedName) {
element.replaceWithText(
`${target.importedName}: ${element.getNameNode().getText()}`
)
}
}
migratedPackages.add(packageName)
changes++
}
return changes
}
function removeVariableDeclaration(declaration: VariableDeclaration) {
const statement = declaration.getVariableStatement()
if (statement?.getDeclarations().length === 1) {
statement.remove()
} else {
declaration.remove()
}
}
function hasReferences(identifier: Identifier, externalContent: string) {
return (
getLiveReferences(identifier).length > 0 ||
containsIdentifier(externalContent, identifier.getText())
)
}
function containsIdentifier(content: string, identifier: string) {
if (!content) {
return false
}
const escaped = identifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
return new RegExp(`(^|[^a-zA-Z0-9_$])${escaped}(?=$|[^a-zA-Z0-9_$])`).test(
content
)
}
function getOldPackage(module: OldModule): OldPackage {
return module.startsWith("clsx") ? "clsx" : "tailwind-merge"
}
function isOldModule(value: string): value is OldModule {
return value === "clsx" || value === "clsx/lite" || value === "tailwind-merge"
}

View File

@@ -0,0 +1,170 @@
import path from "path"
import { Project } from "ts-morph"
import { collapseCanonicalWrappers, findCompositions } from "./bindings"
import { migrateCnfastSpecifiers } from "./cnfast"
import { collectBindings } from "./collect-bindings"
import { addCommonJsCnRequire } from "./emit"
import {
getAstroFrontmatterRegions,
getScriptKind,
getScriptRegions,
normalizeVirtualFilename,
} from "./files"
import { dedupeIssues, getRemainingPackages } from "./issues"
import { getAvailableLocalName } from "./references"
import { migrateSpecifiers } from "./specifiers"
import type {
CnMigrationIssue,
CnSourceTransformResult,
OldPackage,
PendingImport,
SourceRegion,
} from "./types"
export function transformCnSource(
content: string,
filename: string = "source.tsx"
): CnSourceTransformResult {
return createCnTransformer()(content, filename)
}
export function createCnTransformer() {
const project = new Project({
useInMemoryFileSystem: true,
compilerOptions: { allowJs: true },
})
return (content: string, filename: string = "source.tsx") => {
const extension = path.extname(filename).toLowerCase()
if (extension === ".vue" || extension === ".svelte") {
return transformSourceRegions(
content,
getScriptRegions(content, filename),
project
)
}
if (extension === ".astro") {
return transformSourceRegions(
content,
[
...getAstroFrontmatterRegions(content, filename),
...getScriptRegions(content, filename),
],
project
)
}
return transformScriptSource(content, filename, project)
}
}
function transformScriptSource(
content: string,
filename: string,
project: Project,
externalContent = ""
): CnSourceTransformResult {
const sourceFile = project.createSourceFile(
normalizeVirtualFilename(filename),
content,
{
scriptKind: getScriptKind(filename),
overwrite: true,
}
)
const leadingTrivia = content.slice(0, sourceFile.getStart())
if (leadingTrivia) {
sourceFile.replaceText([0, leadingTrivia.length], "")
}
const pendingImports: PendingImport[] = []
const unsupported: CnMigrationIssue[] = []
const migratedPackages = new Set<OldPackage>()
let changes = migrateCnfastSpecifiers(sourceFile, migratedPackages)
const bindings = collectBindings(sourceFile)
changes += collapseCanonicalWrappers(sourceFile, bindings, migratedPackages)
const compositions = findCompositions(sourceFile, bindings)
if (compositions.length) {
const cnLocalName = getAvailableLocalName(sourceFile, "cn", "cnMerge")
const hasEsmComposition = compositions.some(
(composition) => composition.kind === "esm"
)
if (hasEsmComposition) {
pendingImports.push({
moduleSpecifier: "cn",
importedName: "cn",
localName: cnLocalName,
isTypeOnly: false,
})
} else {
addCommonJsCnRequire(sourceFile, cnLocalName)
}
for (const { outerCall, argumentsText } of compositions.reverse()) {
outerCall.replaceWithText(`${cnLocalName}(${argumentsText})`)
changes++
}
migratedPackages.add("clsx")
migratedPackages.add("tailwind-merge")
}
changes += migrateSpecifiers(
sourceFile,
pendingImports,
unsupported,
migratedPackages,
externalContent
)
const transformed = `${leadingTrivia}${sourceFile.getFullText()}`
const result = {
content: transformed,
changes,
migratedPackages: Array.from(migratedPackages),
remainingPackages: getRemainingPackages(transformed),
unsupported: dedupeIssues(unsupported),
}
project.removeSourceFile(sourceFile)
return result
}
function transformSourceRegions(
content: string,
regions: SourceRegion[],
project: Project
) {
let transformed = content
let changes = 0
const migratedPackages = new Set<OldPackage>()
const unsupported: CnMigrationIssue[] = []
for (const region of regions.sort((a, b) => b.start - a.start)) {
const source = transformed.slice(region.start, region.end)
const externalContent = `${transformed.slice(0, region.start)}${transformed.slice(region.end)}`
const result = transformScriptSource(
source,
region.filename,
project,
externalContent
)
transformed = `${transformed.slice(0, region.start)}${result.content}${transformed.slice(region.end)}`
changes += result.changes
result.migratedPackages.forEach((packageName) =>
migratedPackages.add(packageName)
)
unsupported.push(...result.unsupported)
}
return {
content: transformed,
changes,
migratedPackages: Array.from(migratedPackages),
remainingPackages: getRemainingPackages(transformed),
unsupported: dedupeIssues(unsupported),
} satisfies CnSourceTransformResult
}

View File

@@ -0,0 +1,61 @@
import type { Identifier } from "ts-morph"
export const OLD_PACKAGES = ["clsx", "tailwind-merge", "cnfast"] as const
export const TAILWIND_ROOT_EXPORTS = new Set([
"twMerge",
"twJoin",
"ClassNameValue",
])
export const TAILWIND_CONFIG_EXPORTS = new Map([
["extendTailwindMerge", "extendTailwindMerge"],
["createTailwindMerge", "createTwMerge"],
["getDefaultConfig", "defaultConfig"],
["fromTheme", "fromTheme"],
["validators", "validators"],
["mergeConfigs", "mergeConfigs"],
["ConfigExtension", "ConfigExtension"],
["DefaultClassGroupIds", "DefaultClassGroupIds"],
["DefaultThemeGroupIds", "DefaultThemeGroupIds"],
])
export type OldPackage = (typeof OLD_PACKAGES)[number]
export type OldModule = Exclude<OldPackage, "cnfast"> | "clsx/lite"
export type CnModule = "cn" | "cn/config" | "cn/lite"
export type CnMigrationIssue = {
file?: string
packageName: OldPackage
symbol?: string
reason: string
}
export type CnSourceTransformResult = {
content: string
changes: number
migratedPackages: OldPackage[]
remainingPackages: OldPackage[]
unsupported: CnMigrationIssue[]
}
export type Binding = {
identifier: Identifier
localName: string
importedName: string
module: OldModule
kind: "esm" | "cjs"
}
export type PendingImport = {
moduleSpecifier: CnModule
importedName: string
localName: string
isTypeOnly: boolean
}
export type SourceRegion = {
start: number
end: number
filename: string
}

View File

@@ -0,0 +1,178 @@
import { Config } from "@/src/utils/get-config"
import { transformCssVars } from "@/src/utils/updaters/update-css-vars"
import { describe, expect, it } from "vitest"
import { getBaseColorMigration } from "./migrate-base-color"
const SOURCE = {
light: {
background: "oklch(1 0 0)",
ring: "oklch(0.7 0.01 286)",
primary: "oklch(0.2 0 0)",
border: "oklch(0.9 0.01 286)",
},
dark: {
background: "oklch(0.14 0 0)",
ring: "oklch(0.55 0.02 286)",
},
}
const TARGET = {
light: {
background: "oklch(1 0 0)",
ring: "oklch(0.708 0 0)",
primary: "oklch(0.205 0 0)",
border: "oklch(0.922 0 0)",
},
dark: {
background: "oklch(0.14 0 0)",
ring: "oklch(0.556 0 0)",
},
}
describe("getBaseColorMigration", () => {
it("replaces tokens that still hold the source base color value", () => {
const css = `@import "tailwindcss";
:root {
--background: oklch(1 0 0);
--ring: oklch(0.7 0.01 286);
--primary: oklch(0.2 0 0);
--border: oklch(0.9 0.01 286);
}
.dark {
--background: oklch(0.14 0 0);
--ring: oklch(0.55 0.02 286);
}`
const { cssVars, skipped } = getBaseColorMigration(
css,
SOURCE,
TARGET,
"v4"
)
expect(cssVars.light).toEqual({
ring: "oklch(0.708 0 0)",
primary: "oklch(0.205 0 0)",
border: "oklch(0.922 0 0)",
})
expect(cssVars.dark).toEqual({ ring: "oklch(0.556 0 0)" })
expect(skipped).toEqual([])
})
it("keeps and reports tokens that no longer match the source", () => {
const css = `:root {
--ring: oklch(0.7 0.01 286);
--primary: oklch(0.55 0.22 260);
}`
const { cssVars, skipped } = getBaseColorMigration(
css,
SOURCE,
TARGET,
"v4"
)
expect(cssVars.light.ring).toBe("oklch(0.708 0 0)")
expect(cssVars.light.primary).toBeUndefined()
expect(skipped).toContainEqual({
token: "--primary",
reason: "does not match the source base color",
})
})
it("does not touch or report tokens that are equal across base colors", () => {
const css = `:root {
--background: oklch(1 0 0);
--ring: oklch(0.7 0.01 286);
}`
const { cssVars, skipped } = getBaseColorMigration(
css,
SOURCE,
TARGET,
"v4"
)
expect(cssVars.light.background).toBeUndefined()
expect(skipped.map((entry) => entry.token)).not.toContain("--background")
})
it("reports tokens that are missing from the CSS", () => {
const css = `:root {
--ring: oklch(0.7 0.01 286);
}`
const { skipped } = getBaseColorMigration(css, SOURCE, TARGET, "v4")
expect(skipped).toContainEqual({
token: "--border",
reason: "not found in your CSS",
})
})
it("wraps local hsl channels before comparing in v4", () => {
const source = { light: { border: "0 0% 90%" } }
const target = { light: { border: "0 0% 92%" } }
const css = `:root {
--border: hsl(0 0% 90%);
}`
const { cssVars, skipped } = getBaseColorMigration(
css,
source,
target,
"v4"
)
expect(cssVars.light.border).toBe("0 0% 92%")
expect(skipped).toEqual([])
})
})
describe("getBaseColorMigration + transformCssVars", () => {
it("writes the computed subset into the theme CSS for v4", async () => {
const source = {
light: {
ring: "oklch(0.7 0.01 286)",
sidebar: "oklch(0.98 0.01 286)",
},
dark: { ring: "oklch(0.55 0.02 286)" },
}
const target = {
light: {
ring: "oklch(0.708 0 0)",
sidebar: "oklch(0.985 0 0)",
},
dark: { ring: "oklch(0.556 0 0)" },
}
const css = `@import "tailwindcss";
:root {
--ring: oklch(0.7 0.01 286);
--sidebar: oklch(0.98 0.01 286);
--primary: oklch(0.55 0.22 260);
}
.dark {
--ring: oklch(0.55 0.02 286);
}`
const { cssVars } = getBaseColorMigration(css, source, target, "v4")
const output = await transformCssVars(css, cssVars, {} as Config, {
tailwindVersion: "v4",
overwriteCssVars: true,
})
// Stock tokens are rewritten to the target base color in both modes.
expect(output).toContain("--ring: oklch(0.708 0 0)")
expect(output).toContain("--sidebar: oklch(0.985 0 0)")
expect(output).toContain("--ring: oklch(0.556 0 0)")
// Tokens outside the computed subset are left untouched.
expect(output).toContain("--primary: oklch(0.55 0.22 260)")
// The @theme inline color mappings are (re)generated.
expect(output).toContain("@theme inline")
})
})

View File

@@ -0,0 +1,318 @@
import { promises as fs } from "fs"
import path from "path"
import { getRegistryBaseColor } from "@/src/registry/api"
import { BASE_COLORS } from "@/src/registry/constants"
import {
registryBaseColorSchema,
registryItemCssVarsSchema,
} from "@/src/schema"
import { Config } from "@/src/utils/get-config"
import { getProjectInfo, TailwindVersion } from "@/src/utils/get-project-info"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import {
isLocalHSLValue,
updateCssVars,
} from "@/src/utils/updaters/update-css-vars"
import fsExtra from "fs-extra"
import postcss from "postcss"
import prompts from "prompts"
import { z } from "zod"
type CssVars = z.infer<typeof registryItemCssVarsSchema>
export interface SkippedToken {
token: string
reason: string
}
export async function migrateBaseColor(
config: Config,
options: {
from?: string
to?: string
yes?: boolean
} = {}
) {
if (!config.resolvedPaths.tailwindCss) {
throw new Error(
"We could not find a valid CSS file in your `components.json` file. Please ensure you have a valid `tailwind.css` path in your `components.json` file."
)
}
if (!config.tailwind.cssVariables) {
throw new Error(
"The `base-color` migration requires CSS variables. Your `components.json` has `cssVariables: false`, which uses inline Tailwind color classes instead of theme variables."
)
}
const baseColorChoices = BASE_COLORS.map((baseColor) => ({
title: baseColor.label,
value: baseColor.name,
}))
const baseColorNames: string[] = BASE_COLORS.map(
(baseColor) => baseColor.name
)
// Only the target is validated. The source can be a legacy base color
// (e.g. slate) an existing project still uses.
if (options.to && !baseColorNames.includes(options.to)) {
throw new Error(
`Unknown base color: ${options.to}. Available base colors: ${baseColorNames.join(
", "
)}.`
)
}
// Default the source to the project's current base color.
let sourceBaseColor = options.from || config.tailwind.baseColor
let targetBaseColor = options.to
if (!sourceBaseColor || !targetBaseColor) {
const currentBaseColorIndex = baseColorChoices.findIndex(
(choice) => choice.value === config.tailwind.baseColor
)
const migrateOptions = await prompts([
{
type: sourceBaseColor ? null : "select",
name: "sourceBaseColor",
message: `Which base color would you like to ${highlighter.info(
"migrate from"
)}?`,
choices: baseColorChoices,
initial: currentBaseColorIndex === -1 ? 0 : currentBaseColorIndex,
},
{
type: targetBaseColor ? null : "select",
name: "targetBaseColor",
message: `Which base color would you like to ${highlighter.info(
"migrate to"
)}?`,
choices: baseColorChoices,
},
])
sourceBaseColor = sourceBaseColor || migrateOptions.sourceBaseColor
targetBaseColor = targetBaseColor || migrateOptions.targetBaseColor
}
if (!sourceBaseColor || !targetBaseColor) {
logger.info("Migration cancelled.")
process.exit(0)
}
if (sourceBaseColor === targetBaseColor) {
throw new Error(
"You cannot migrate to the same base color. Please choose a different base color."
)
}
if (!options.yes) {
const relativePath = `./${path.relative(
config.resolvedPaths.cwd,
config.resolvedPaths.tailwindCss
)}`
const { confirm } = await prompts({
type: "confirm",
name: "confirm",
initial: true,
message: `We will migrate ${highlighter.info(
relativePath
)} from ${highlighter.info(sourceBaseColor)} to ${highlighter.info(
targetBaseColor
)}. Continue?`,
})
if (!confirm) {
logger.info("Migration cancelled.")
process.exit(0)
}
}
const sourceColor = await getRegistryBaseColor(sourceBaseColor)
const targetColor = await getRegistryBaseColor(targetBaseColor)
if (!sourceColor) {
throw new Error(`Unknown base color: ${sourceBaseColor}.`)
}
if (!targetColor) {
throw new Error("Something went wrong fetching the base colors.")
}
const projectInfo = await getProjectInfo(config.resolvedPaths.cwd)
const tailwindVersion = projectInfo?.tailwindVersion ?? "v4"
const sourceVars = getBaseColorCssVars(sourceColor, tailwindVersion)
const targetVars = getBaseColorCssVars(targetColor, tailwindVersion)
const migrationSpinner = spinner(`Migrating base color...`)?.start()
const raw = await fs.readFile(config.resolvedPaths.tailwindCss, "utf-8")
const { cssVars, skipped } = getBaseColorMigration(
raw,
sourceVars,
targetVars,
tailwindVersion
)
if (
Object.keys(cssVars.light).length > 0 ||
Object.keys(cssVars.dark).length > 0
) {
await updateCssVars(cssVars, config, {
overwriteCssVars: true,
tailwindVersion,
silent: true,
})
}
migrationSpinner.succeed("Migration complete.")
// Keep components.json in sync so future `shadcn add` installs use the new
// base color.
await updateConfigBaseColor(config, targetBaseColor)
const skippedTokens = new Map<string, string>()
for (const { token, reason } of skipped) {
if (!skippedTokens.has(token)) {
skippedTokens.set(token, reason)
}
}
if (skippedTokens.size > 0) {
logger.break()
logger.warn(
`Skipped ${skippedTokens.size} token${
skippedTokens.size === 1 ? "" : "s"
}. These were left untouched:`
)
for (const [token, reason] of Array.from(skippedTokens)) {
logger.warn(` - ${token}: ${reason}`)
}
}
}
export function getBaseColorMigration(
css: string,
sourceVars: CssVars,
targetVars: CssVars,
tailwindVersion: TailwindVersion
): {
cssVars: { light: Record<string, string>; dark: Record<string, string> }
skipped: SkippedToken[]
} {
const currentVars = readCssVars(css)
const light: Record<string, string> = {}
const dark: Record<string, string> = {}
const skipped: SkippedToken[] = []
const modes = [
{ mode: "light", selector: ":root", target: light },
{ mode: "dark", selector: ".dark", target: dark },
] as const
for (const { mode, selector, target } of modes) {
for (const [name, sourceValue] of Object.entries(sourceVars[mode] ?? {})) {
const targetValue = targetVars[mode]?.[name]
// Skip tokens that are identical in both base colors (e.g. radius).
if (
targetValue === undefined ||
normalizeValue(sourceValue) === normalizeValue(targetValue)
) {
continue
}
const prop = `--${name.replace(/^--/, "")}`
const currentValue = currentVars[selector][prop]
if (currentValue === undefined) {
skipped.push({ token: prop, reason: "not found in your CSS" })
continue
}
// Only replace tokens that still hold the source base color value.
if (
normalizeValue(currentValue) ===
normalizeValue(getWrittenValue(sourceValue, tailwindVersion))
) {
target[name] = targetValue
} else {
skipped.push({
token: prop,
reason: "does not match the source base color",
})
}
}
}
return { cssVars: { light, dark }, skipped }
}
function getBaseColorCssVars(
baseColor: z.infer<typeof registryBaseColorSchema>,
tailwindVersion: TailwindVersion
): CssVars {
if (tailwindVersion === "v4" && baseColor.cssVarsV4) {
return baseColor.cssVarsV4
}
return baseColor.cssVars
}
function readCssVars(css: string) {
const vars: Record<string, Record<string, string>> = {
":root": {},
".dark": {},
}
postcss.parse(css).walkRules((rule) => {
if (rule.selector !== ":root" && rule.selector !== ".dark") {
return
}
rule.walkDecls((declaration) => {
if (declaration.prop.startsWith("--")) {
vars[rule.selector][declaration.prop] = declaration.value
}
})
})
return vars
}
function getWrittenValue(value: string, tailwindVersion: TailwindVersion) {
// Local HSL channels are wrapped in hsl() when written to CSS in v4.
if (tailwindVersion === "v4" && isLocalHSLValue(value)) {
return `hsl(${value})`
}
return value
}
function normalizeValue(value: string) {
return value.trim().replace(/\s+/g, " ")
}
async function updateConfigBaseColor(config: Config, baseColor: string) {
const targetPath = path.resolve(config.resolvedPaths.cwd, "components.json")
if (!fsExtra.existsSync(targetPath)) {
return
}
const rawConfig = await fsExtra.readJson(targetPath)
if (rawConfig.tailwind?.baseColor === baseColor) {
return
}
rawConfig.tailwind = { ...rawConfig.tailwind, baseColor }
await fsExtra.writeJson(targetPath, rawConfig, { spaces: 2 })
logger.info(
`Updated ${highlighter.info(
"baseColor"
)} in components.json to ${highlighter.info(baseColor)}.`
)
}

View File

@@ -0,0 +1,986 @@
import { promises as fs } from "fs"
import { tmpdir } from "os"
import path from "path"
import {
installDependencies,
removeDependencies,
} from "@/src/utils/updaters/update-dependencies"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { createCnTransformer } from "./cn/transform"
import { migrateCn, transformCnSource } from "./migrate-cn"
vi.mock("@/src/utils/spinner", () => ({
spinner: vi.fn(() => ({
start: vi.fn().mockReturnThis(),
succeed: vi.fn(),
fail: vi.fn(),
text: "",
})),
}))
vi.mock("@/src/utils/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
break: vi.fn(),
},
}))
vi.mock("@/src/utils/updaters/update-dependencies", () => ({
installDependencies: vi.fn(),
removeDependencies: vi.fn(),
}))
describe("transformCnSource", () => {
it("replaces an exported shadcn helper with a direct re-export", () => {
const result =
transformCnSource(`import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
`)
expect(result.content.trim()).toBe('export { cn } from "cn";')
expect(result.migratedPackages).toEqual(["clsx", "tailwind-merge"])
expect(result.remainingPackages).toEqual([])
})
it("replaces a separately exported function declaration", () => {
const result = transformCnSource(`import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
function cn(...inputs) {
return twMerge(clsx(inputs))
}
export { cn }
`)
expect(result.content.trim()).toBe('export { cn } from "cn";')
})
it("replaces a separately exported const declaration", () => {
const result = transformCnSource(`import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
const cn = (...inputs) => twMerge(clsx(inputs))
export { cn }
`)
expect(result.content.trim()).toBe('export { cn } from "cn";')
})
it("preserves an aliased public export", () => {
const result = transformCnSource(`import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
const cx = (...inputs) => twMerge(clsx(inputs))
export { cx }
`)
expect(result.content.trim()).toBe('export { cn as cx } from "cn";')
})
it("keeps a locally used helper and aliases the package import", () => {
const result =
transformCnSource(`import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
const buttonClassName = cn("px-2", "px-4")
export { buttonClassName, cn }
`)
expect(result.content).toContain(
'import { cn as cnMerge, type ClassValue } from "cn"'
)
expect(result.content).toContain("return cnMerge(...inputs)")
expect(result.content).toContain("export { buttonClassName, cn }")
expect(result.content).not.toContain('from "clsx"')
expect(result.content).not.toContain('from "tailwind-merge"')
})
it("preserves a default-exported helper", () => {
const result = transformCnSource(`import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
const cn = (...inputs) => twMerge(clsx(inputs))
export default cn
`)
expect(result.content).toContain('import { cn as cnMerge } from "cn"')
expect(result.content).toContain(
"const cn = (...inputs) => cnMerge(...inputs)"
)
expect(result.content).toContain("export default cn")
})
it("preserves an inline default export when collapsing a helper", () => {
const result = transformCnSource(`import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export default function cn(...inputs) {
return twMerge(clsx(inputs))
}
`)
expect(result.content.trim()).toBe('export { cn as default } from "cn";')
})
it("preserves named and default exports for the same helper", () => {
const result = transformCnSource(`import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export default function cn(...inputs) {
return twMerge(clsx(inputs))
}
export { cn }
`)
expect(result.content).toContain("cn as default")
expect(result.content).toMatch(/export \{[^}]*\bcn\b[^}]*\} from "cn"/)
})
it("does not crash on an anonymous default-exported helper", () => {
const result = transformCnSource(`import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export default function (...inputs) {
return twMerge(clsx(inputs))
}
`)
expect(result.content).toContain('import { cn } from "cn"')
expect(result.content).toContain("export default function (...inputs)")
expect(result.content).toContain("return cn(...inputs)")
})
it("collapses helpers that spread their rest parameter into clsx", () => {
const result = transformCnSource(`import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(...inputs))
}
`)
expect(result.content.trim()).toBe('export { cn } from "cn";')
})
it("rewrites a composed default clsx import", () => {
const result = transformCnSource(`import cx from "clsx"
import { twMerge as merge } from "tailwind-merge"
const className = merge(cx("px-2", active && "px-4"))
`)
expect(result.content).toContain('import { cn } from "cn"')
expect(result.content).toContain(
'const className = cn("px-2", active && "px-4")'
)
expect(result.content).not.toContain("clsx")
expect(result.content).not.toContain("tailwind-merge")
})
it("flattens clsx calls mixed with other twMerge arguments", () => {
const result = transformCnSource(`import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
const className = twMerge("px-1", clsx(active && "px-2"), "px-4")
`)
expect(result.content).toContain(
'const className = cn("px-1", active && "px-2", "px-4")'
)
})
it("keeps separate clsx and twMerge calls as separate APIs", () => {
const result = transformCnSource(`import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
const conditional = clsx("flex", enabled && "block")
const merged = twMerge("px-2 px-4")
`)
expect(result.content).toContain('import { clsx, twMerge } from "cn"')
expect(result.content).toContain(
'const conditional = clsx("flex", enabled && "block")'
)
expect(result.content).toContain('const merged = twMerge("px-2 px-4")')
})
it("swaps cnfast module specifiers without changing its API", () => {
const result =
transformCnSource(`import { cn, clsx, twMerge, twJoin } from "cnfast"
export { cn as merge } from "cnfast"
export * from "cnfast"
export const loadCn = () => import("cnfast")
export const runtime = require("cnfast")
export const classes = cn(clsx("px-2"), twMerge("px-4"), twJoin("flex"))
`)
expect(result.content).not.toContain('"cnfast"')
expect(result.content).toContain(
'import { cn, clsx, twMerge, twJoin } from "cn"'
)
expect(result.content).toContain('export { cn as merge } from "cn"')
expect(result.content).toContain('export * from "cn"')
expect(result.content).toContain('import("cn")')
expect(result.content).toContain('require("cn")')
expect(result.migratedPackages).toEqual(["cnfast"])
expect(result.remainingPackages).toEqual([])
expect(result.unsupported).toEqual([])
})
it("splits root and custom-config imports", () => {
const result = transformCnSource(`import {
twMerge,
extendTailwindMerge,
createTailwindMerge,
getDefaultConfig,
type ConfigExtension,
} from "tailwind-merge"
export { twMerge, extendTailwindMerge, createTailwindMerge, getDefaultConfig }
export type { ConfigExtension }
`)
expect(result.content).toContain('import { twMerge } from "cn"')
expect(result.content).toContain(
'import { extendTailwindMerge, createTwMerge as createTailwindMerge, defaultConfig as getDefaultConfig, type ConfigExtension } from "cn/config"'
)
expect(result.remainingPackages).toEqual([])
})
it("moves clsx/lite runtime imports and root types separately", () => {
const result =
transformCnSource(`import clsx, { type ClassValue } from "clsx/lite"
export const classes = (...inputs: ClassValue[]) => clsx(inputs)
`)
expect(result.content).toContain('import { clsx } from "cn/lite"')
expect(result.content).toContain('import { type ClassValue } from "cn"')
})
it("maps a named default clsx import to the named cn export", () => {
const result = transformCnSource(`import { default as cx } from "clsx/lite"
export const classes = cx("flex", active && "block")
`)
expect(result.content).toContain('import { clsx as cx } from "cn/lite"')
expect(result.content).not.toContain('from "clsx/lite"')
})
it("migrates clsx imports in JavaScript files", () => {
const result = transformCnSource(
`import { clsx } from "clsx"
export const classes = clsx("flex")
`,
"classes.js"
)
expect(result.content).toContain('import { clsx } from "cn"')
expect(result.content).toContain('export const classes = clsx("flex")')
})
it("migrates CommonJS requires in cjs files", () => {
const result = transformCnSource(
`const cx = require("clsx")
module.exports = cx("flex")
`,
"classes.cjs"
)
expect(result.content).toContain('const cx = require("cn").clsx')
expect(result.content).toContain('module.exports = cx("flex")')
})
it("migrates compositions in JSX files", () => {
const result = transformCnSource(
`import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export const element = <div className={twMerge(clsx("px-2"))} />
`,
"component.jsx"
)
expect(result.content).toContain('import { cn } from "cn"')
expect(result.content).toContain('<div className={cn("px-2")} />')
})
it("leaves direct validator calls for manual review", () => {
const result =
transformCnSource(`import { validators } from "tailwind-merge"
export const valid = validators.isNumber("2")
`)
expect(result.content).toContain('from "tailwind-merge"')
expect(result.remainingPackages).toEqual(["tailwind-merge"])
expect(result.unsupported).toContainEqual({
packageName: "tailwind-merge",
symbol: "validators",
reason: "callable validators are not supported by cn",
})
})
it("leaves destructured validators for manual review", () => {
const result =
transformCnSource(`import { validators } from "tailwind-merge"
const { isNumber } = validators
export const valid = isNumber("2")
`)
expect(result.content).toContain('from "tailwind-merge"')
expect(result.unsupported).toContainEqual({
packageName: "tailwind-merge",
symbol: "validators",
reason: "callable validators are not supported by cn",
})
})
it("migrates validators used as configuration markers", () => {
const result = transformCnSource(`import {
extendTailwindMerge,
validators,
} from "tailwind-merge"
export const merge = extendTailwindMerge({
extend: { classGroups: { tab: [validators.isNumber] } },
})
`)
expect(result.content).toContain(
'import { extendTailwindMerge, validators } from "cn/config"'
)
expect(result.remainingPackages).toEqual([])
})
it("leaves variadic createTailwindMerge calls for manual review", () => {
const result =
transformCnSource(`import { createTailwindMerge } from "tailwind-merge"
export const merge = createTailwindMerge(() => config, () => extension)
`)
expect(result.content).toContain('from "tailwind-merge"')
expect(result.unsupported).toContainEqual({
packageName: "tailwind-merge",
symbol: "createTailwindMerge",
reason: "variadic createTailwindMerge calls are not supported by cn",
})
})
it("leaves unsupported experimental imports for manual review", () => {
const result = transformCnSource(`import {
twMerge,
type ExperimentalParseClassNameParam,
} from "tailwind-merge"
export { twMerge }
export type { ExperimentalParseClassNameParam }
`)
expect(result.content).toContain('import { twMerge } from "cn"')
expect(result.content).toContain("type ExperimentalParseClassNameParam")
expect(result.content).toContain('from "tailwind-merge"')
expect(result.remainingPackages).toEqual(["tailwind-merge"])
expect(result.unsupported).toEqual([
{
packageName: "tailwind-merge",
symbol: "ExperimentalParseClassNameParam",
reason: "experimentalParseClassName is not supported by cn",
},
])
})
it("reports dynamic imports without changing them", () => {
const input = `export const loadMerge = () => import("tailwind-merge")
`
const result = transformCnSource(input)
expect(result.content).toBe(input)
expect(result.remainingPackages).toEqual(["tailwind-merge"])
expect(result.unsupported).toEqual([
{
packageName: "tailwind-merge",
reason: "dynamic imports require manual migration",
},
])
})
it("reports export-from declarations without changing them", () => {
const input = `export { twMerge } from "tailwind-merge"
export * from "clsx"
`
const result = transformCnSource(input)
expect(result.content).toBe(input)
expect(result.remainingPackages).toEqual(["clsx", "tailwind-merge"])
expect(result.unsupported).toEqual([
{
packageName: "tailwind-merge",
symbol: "twMerge",
reason: "export-from declarations require manual migration",
},
{
packageName: "clsx",
reason: "export-from declarations require manual migration",
},
])
})
it("migrates supported CommonJS requires", () => {
const result = transformCnSource(`const cx = require("clsx")
const { twMerge } = require("tailwind-merge")
module.exports = (...inputs) => twMerge(cx(inputs))
`)
expect(result.content).toContain('const { cn } = require("cn")')
expect(result.content).toContain(
"module.exports = (...inputs) => cn(...inputs)"
)
expect(result.content).not.toContain("const cx")
expect(result.content).not.toContain("twMerge")
expect(result.remainingPackages).toEqual([])
})
it("keeps referenced CommonJS clsx bindings on cn", () => {
const result = transformCnSource(`const cx = require("clsx")
const { twMerge } = require("tailwind-merge")
const merged = twMerge(cx("px-2", "px-4"))
const joined = cx("flex", active && "block")
module.exports = { merged, joined }
`)
expect(result.content).toContain('const cx = require("cn").clsx')
expect(result.content).toContain('const { cn } = require("cn")')
expect(result.content).toContain('const merged = cn("px-2", "px-4")')
expect(result.content).toContain(
'const joined = cx("flex", active && "block")'
)
})
it("leaves unsafe CommonJS config calls for manual review", () => {
const result =
transformCnSource(`const { validators } = require("tailwind-merge")
module.exports = validators.isNumber("2")
`)
expect(result.content).toContain('require("tailwind-merge")')
expect(result.unsupported).toContainEqual({
packageName: "tailwind-merge",
symbol: "validators",
reason: "callable validators are not supported by cn",
})
})
it("reports CommonJS requires that span multiple cn entrypoints", () => {
const result = transformCnSource(`const {
twMerge,
extendTailwindMerge,
} = require("tailwind-merge")
module.exports = { twMerge, extendTailwindMerge }
`)
expect(result.content).toContain('require("tailwind-merge")')
expect(result.unsupported).toContainEqual({
packageName: "tailwind-merge",
reason: "mixed or unsupported CommonJS exports require manual migration",
})
})
it("transforms Vue script blocks without changing the template", () => {
const result = transformCnSource(
`<script setup lang="ts">
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
const classes = twMerge(clsx("px-2", active && "px-4"))
</script>
<template><div :class="classes" /></template>
`,
"component.vue"
)
expect(result.content).toContain('import { cn } from "cn"')
expect(result.content).toContain(
'const classes = cn("px-2", active && "px-4")'
)
expect(result.content).toContain(
'<template><div :class="classes" /></template>'
)
})
it("preserves Vue imports referenced only by the template", () => {
const result = transformCnSource(
`<script setup lang="ts">
import { clsx } from "clsx"
</script>
<template><div :class="clsx('flex', active && 'block')" /></template>
`,
"component.vue"
)
expect(result.content).toContain('import { clsx } from "cn"')
expect(result.content).toContain(
`<template><div :class="clsx('flex', active && 'block')" /></template>`
)
})
it("transforms browser-tolerated script closing tags", () => {
const result = transformCnSource(
`<script setup lang="ts">
import { clsx } from "clsx"
const classes = clsx("flex")
</script\t\n bar>
<template><div :class="classes" /></template>
`,
"component.vue"
)
expect(result.content).toContain('import { clsx } from "cn"')
expect(result.content).toContain('const classes = clsx("flex")')
expect(result.content).toContain("</script\t\n bar>")
})
it("preserves Svelte imports referenced only by the markup", () => {
const result = transformCnSource(
`<script lang="ts">
import { clsx } from "clsx"
</script>
<div class={clsx("flex", active && "block")} />
`,
"component.svelte"
)
expect(result.content).toContain('import { clsx } from "cn"')
expect(result.content).toContain(
'<div class={clsx("flex", active && "block")} />'
)
})
it("transforms plain JavaScript Svelte script blocks", () => {
const result = transformCnSource(
`<script>
import { clsx } from "clsx"
const classes = clsx("flex", active && "block")
</script>
<div class={classes} />
`,
"component.svelte"
)
expect(result.content).toContain('import { clsx } from "cn"')
expect(result.content).toContain(
'const classes = clsx("flex", active && "block")'
)
})
it("transforms Astro frontmatter", () => {
const result = transformCnSource(
`---
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
const classes = twMerge(clsx("px-2", Astro.props.className))
---
<div class={classes}><slot /></div>
`,
"component.astro"
)
expect(result.content).toContain('import { cn } from "cn"')
expect(result.content).toContain(
'const classes = cn("px-2", Astro.props.className)'
)
})
it("preserves Astro imports referenced only by the template", () => {
const result = transformCnSource(
`---
import { clsx } from "clsx"
---
<div class={clsx("flex", Astro.props.active && "block")} />
`,
"component.astro"
)
expect(result.content).toContain('import { clsx } from "cn"')
expect(result.content).toContain(
'<div class={clsx("flex", Astro.props.active && "block")} />'
)
})
it("parses legacy TypeScript assertions without treating them as JSX", () => {
const result = transformCnSource(
`import { clsx } from "clsx"
const element = <HTMLInputElement>document.getElementById("field")
export const classes = clsx("flex", element.disabled && "opacity-50")
`,
"classes.ts"
)
expect(result.content).toContain('import { clsx } from "cn"')
expect(result.content).toContain(
'const element = <HTMLInputElement>document.getElementById("field")'
)
expect(result.content).toContain(
'export const classes = clsx("flex", element.disabled && "opacity-50")'
)
})
it("uses the script language when parsing embedded TypeScript", () => {
const result = transformCnSource(
`<script setup lang="ts">
import { clsx } from "clsx"
const element = <HTMLInputElement>document.getElementById("field")
const classes = clsx("flex", element.disabled && "opacity-50")
</script>
<template><div :class="classes" /></template>
`,
"component.vue"
)
expect(result.content).toContain('import { clsx } from "cn"')
expect(result.content).toContain(
'const element = <HTMLInputElement>document.getElementById("field")'
)
})
it("does not retain imports for shadowed ESM bindings", () => {
const result = transformCnSource(`import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
const merged = twMerge(clsx("px-2", "px-4"))
export function join(clsx) {
return clsx("flex")
}
`)
expect(result.content).toContain('import { cn } from "cn"')
expect(result.content).not.toMatch(/import \{[^}]*clsx[^}]*\} from "cn"/)
expect(result.content).toContain('return clsx("flex")')
})
it("does not retain references from earlier transformed files", () => {
const transform = createCnTransformer()
transform(
`import { cn } from "./utils"
export const classes = cn("px-2")
`,
"/repo/src/lib/button.tsx"
)
const utils = transform(
`import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs))
}
`,
"/repo/src/lib/utils.ts"
)
expect(utils.content.trim()).toBe('export { cn } from "cn";')
})
it("is idempotent", () => {
const input = `import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export const classes = twMerge(clsx("px-2", active && "px-4"))
`
const first = transformCnSource(input)
const second = transformCnSource(first.content)
expect(second.content).toBe(first.content)
expect(second.changes).toBe(0)
})
it("does not replace unrelated functions with the same names", () => {
const input = `function clsx(value) {
return value
}
function twMerge(value) {
return value
}
export const cn = (value) => twMerge(clsx(value))
`
expect(transformCnSource(input).content).toBe(input)
})
it("preserves leading comments in unchanged files", () => {
const input = `/**
* This component is adapted from an external example.
*/
"use client"
export const classes = "flex"
`
const result = transformCnSource(input)
expect(result.content).toBe(input)
expect(result.changes).toBe(0)
})
it("preserves leading comments in migrated files", () => {
const result =
transformCnSource(`// Copyright Example Authors. Licensed under MIT.
// This file is generated.
import { clsx } from "clsx"
export const classes = clsx("flex")
`)
expect(result.content)
.toBe(`// Copyright Example Authors. Licensed under MIT.
// This file is generated.
import { clsx } from "cn";
export const classes = clsx("flex")
`)
})
})
describe("migrateCn", () => {
let cwd: string
beforeEach(async () => {
cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-cn-migrate-"))
await fs.mkdir(path.join(cwd, "src"), { recursive: true })
vi.mocked(installDependencies).mockReset().mockResolvedValue(undefined)
vi.mocked(removeDependencies).mockReset().mockResolvedValue(undefined)
})
afterEach(async () => {
await fs.rm(cwd, { recursive: true, force: true })
})
async function setupProject({
source,
dependencies,
}: {
source: string
dependencies: Record<string, string>
}) {
await fs.writeFile(
path.join(cwd, "package.json"),
JSON.stringify({ name: "fixture", dependencies }, null, 2)
)
await fs.writeFile(path.join(cwd, "src/classes.ts"), source)
}
it("requires package.json without requiring components.json", async () => {
await expect(migrateCn({ cwd, yes: true })).rejects.toThrow(
"No `package.json` file found"
)
expect(installDependencies).not.toHaveBeenCalled()
})
it("migrates a package project without components.json", async () => {
await setupProject({
source: `import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
`,
dependencies: {
clsx: "^2.1.1",
"tailwind-merge": "^3.3.1",
tailwindcss: "^4.1.0",
},
})
await migrateCn({ cwd, yes: true })
expect(
(await fs.readFile(path.join(cwd, "src/classes.ts"), "utf-8")).trim()
).toBe('export { cn } from "cn";')
expect(installDependencies).toHaveBeenCalledWith(cwd, ["cn"])
expect(removeDependencies).toHaveBeenCalledWith(cwd, [
"clsx",
"tailwind-merge",
])
})
it("retains old dependencies for a scoped migration", async () => {
await setupProject({
source: `import { clsx } from "clsx"
export const classes = clsx("flex")
`,
dependencies: { clsx: "^2.1.1" },
})
await migrateCn({ cwd, path: "src/classes.ts", yes: true })
expect(installDependencies).toHaveBeenCalledWith(cwd, ["cn"])
expect(removeDependencies).not.toHaveBeenCalled()
})
it("does not write source files when installing cn fails", async () => {
const source = `import { clsx } from "clsx"
export const classes = clsx("flex")
`
await setupProject({
source,
dependencies: { clsx: "^2.1.1" },
})
vi.mocked(installDependencies).mockRejectedValue(
new Error("install failed")
)
await expect(migrateCn({ cwd, yes: true })).rejects.toThrow(
"install failed"
)
expect(await fs.readFile(path.join(cwd, "src/classes.ts"), "utf-8")).toBe(
source
)
})
it("refuses to migrate tailwind-merge v2 projects", async () => {
const source = `import { twMerge } from "tailwind-merge"
export const classes = twMerge("px-2 px-4")
`
await setupProject({
source,
dependencies: {
"tailwind-merge": "^2.6.0",
tailwindcss: "^3.4.0",
},
})
await expect(migrateCn({ cwd, yes: true })).rejects.toThrow(
"requires Tailwind CSS v4"
)
expect(installDependencies).not.toHaveBeenCalled()
expect(await fs.readFile(path.join(cwd, "src/classes.ts"), "utf-8")).toBe(
source
)
})
it("refuses versions older than the supported Tailwind pair", async () => {
const source = `import { twMerge } from "tailwind-merge"
export const classes = twMerge("px-2 px-4")
`
await setupProject({
source,
dependencies: {
"tailwind-merge": "^1.14.0",
tailwindcss: "^2.2.19",
},
})
await expect(migrateCn({ cwd, yes: true })).rejects.toThrow(
"requires Tailwind CSS v4"
)
expect(installDependencies).not.toHaveBeenCalled()
})
it("allows a clsx-only migration in a Tailwind CSS v3 project", async () => {
await setupProject({
source: `import clsx from "clsx"
export const classes = clsx("flex")
`,
dependencies: {
clsx: "^2.1.1",
tailwindcss: "^3.4.0",
},
})
await migrateCn({ cwd, yes: true })
expect(installDependencies).toHaveBeenCalledWith(cwd, ["cn"])
expect(removeDependencies).toHaveBeenCalledWith(cwd, ["clsx"])
})
it("migrates cnfast and removes its dependency", async () => {
await setupProject({
source: `export { cn } from "cnfast"
`,
dependencies: { cnfast: "^0.1.0" },
})
await migrateCn({ cwd, yes: true })
expect(
(await fs.readFile(path.join(cwd, "src/classes.ts"), "utf-8")).trim()
).toBe('export { cn } from "cn"')
expect(installDependencies).toHaveBeenCalledWith(cwd, ["cn"])
expect(removeDependencies).toHaveBeenCalledWith(cwd, ["cnfast"])
})
it("scans dot directories before removing old dependencies", async () => {
await setupProject({
source: `import { clsx } from "clsx"
export const classes = clsx("flex")
`,
dependencies: { clsx: "^2.1.1" },
})
await fs.mkdir(path.join(cwd, ".storybook"), { recursive: true })
await fs.writeFile(
path.join(cwd, ".storybook/preview.ts"),
'export * from "clsx"\n'
)
await migrateCn({ cwd, yes: true })
expect(removeDependencies).not.toHaveBeenCalled()
})
it("throws when an explicit glob matches no files", async () => {
await setupProject({
source: `export const classes = "flex"
`,
dependencies: {},
})
await expect(
migrateCn({ cwd, path: "missing/**/*.ts", yes: true })
).rejects.toThrow("No files found matching")
})
})

View File

@@ -0,0 +1,2 @@
export { migrateCn, transformCnSource } from "./cn"
export type { CnMigrationIssue, CnSourceTransformResult } from "./cn"

View File

@@ -0,0 +1,542 @@
import { describe, expect, it } from "vitest"
import { migrateIconsFile, migrateIconsFileWithReport } from "./migrate-icons"
// A mapping fixture with full cross-library coverage.
const FULL_MAPPING = {
Check: {
lucide: "Check",
radix: "CheckIcon",
tabler: "IconCheck",
hugeicons: "Tick02Icon",
phosphor: "CheckIcon",
remixicon: "RiCheckLine",
},
ChevronDown: {
lucide: "ChevronDown",
radix: "ChevronDownIcon",
tabler: "IconChevronDown",
hugeicons: "ArrowDown01Icon",
phosphor: "CaretDownIcon",
remixicon: "RiArrowDownSLine",
},
}
describe("migrateIconsFile", () => {
it("should replace radix icons with lucide icons", async () => {
const input = `
import { CheckIcon, CloseIcon } from "@radix-ui/react-icons"
import { Something } from "other-package"
export function Component() {
return (
<div>
<CheckIcon className="w-4 h-4" />
<CloseIcon />
</div>
)
}`
expect(
await migrateIconsFile(input, "radix", "lucide", {
Check: {
lucide: "Check",
radix: "CheckIcon",
},
X: {
lucide: "X",
radix: "CloseIcon",
},
})
).toMatchInlineSnapshot(`
"import { Something } from "other-package"
import { Check, X } from "lucide-react";
export function Component() {
return (
<div>
<Check className="w-4 h-4" />
<X />
</div>
)
}"
`)
})
it("should return null if no radix icons are found", async () => {
const input = `
import { Something } from "other-package"
export function Component() {
return <div>No icons here</div>
}`
expect(await migrateIconsFile(input, "lucide", "radix", {}))
.toMatchInlineSnapshot(`
"import { Something } from "other-package"
export function Component() {
return <div>No icons here</div>
}"
`)
})
it("should handle mixed icon imports from different packages", async () => {
const input = `
import { CheckIcon } from "@radix-ui/react-icons"
import { AlertCircle } from "lucide-react"
import { Something } from "other-package"
import { Cross2Icon } from "@radix-ui/react-icons"
export function Component() {
return (
<div>
<CheckIcon className="w-4 h-4" />
<AlertCircle />
<Cross2Icon />
</div>
)
}`
expect(
await migrateIconsFile(input, "radix", "lucide", {
Check: {
lucide: "Check",
radix: "CheckIcon",
},
X: {
lucide: "X",
radix: "Cross2Icon",
},
})
).toMatchInlineSnapshot(`
"import { AlertCircle } from "lucide-react"
import { Something } from "other-package"
import { Check, X } from "lucide-react";
export function Component() {
return (
<div>
<Check className="w-4 h-4" />
<AlertCircle />
<X />
</div>
)
}"
`)
})
it("should preserve all props and children on icons", async () => {
const input = `
import { CheckIcon, Cross2Icon } from "@radix-ui/react-icons"
export function Component() {
return (
<div>
<CheckIcon
className="w-4 h-4"
onClick={handleClick}
data-testid="check-icon"
>
<span>Child content</span>
</CheckIcon>
<Cross2Icon style={{ color: 'red' }} aria-label="Close" />
</div>
)
}`
expect(
await migrateIconsFile(input, "radix", "lucide", {
Check: {
lucide: "Check",
radix: "CheckIcon",
},
X: {
lucide: "X",
radix: "Cross2Icon",
},
})
).toMatchInlineSnapshot(`
"import { Check, X } from "lucide-react";
export function Component() {
return (
<div>
<Check
className="w-4 h-4"
onClick={handleClick}
data-testid="check-icon"
>
<span>Child content</span>
</Check>
<X style={{ color: 'red' }} aria-label="Close" />
</div>
)
}"
`)
})
it("should leave unmapped icons untouched", async () => {
const input = `
import { CheckIcon, MagicWandIcon } from "@radix-ui/react-icons"
export function Component() {
return (
<div>
<CheckIcon />
<MagicWandIcon />
</div>
)
}`
expect(
await migrateIconsFile(input, "radix", "lucide", {
Check: {
lucide: "Check",
radix: "CheckIcon",
},
})
).toMatchInlineSnapshot(`
"import { MagicWandIcon } from "@radix-ui/react-icons"
import { Check } from "lucide-react";
export function Component() {
return (
<div>
<Check />
<MagicWandIcon />
</div>
)
}"
`)
})
it("should migrate from lucide to radix", async () => {
const input = `
import { Check, X } from "lucide-react"
export function Component() {
return (
<div>
<Check className="size-4" />
<X />
</div>
)
}`
expect(
await migrateIconsFile(input, "lucide", "radix", {
Check: {
lucide: "Check",
radix: "CheckIcon",
},
X: {
lucide: "X",
radix: "Cross2Icon",
},
})
).toMatchInlineSnapshot(`
"import { CheckIcon, Cross2Icon } from "@radix-ui/react-icons";
export function Component() {
return (
<div>
<CheckIcon className="size-4" />
<Cross2Icon />
</div>
)
}"
`)
})
it("should handle aliased imports", async () => {
const input = `
import { CheckIcon as Check } from "@radix-ui/react-icons"
export function Component() {
return <Check />
}`
expect(
await migrateIconsFile(input, "radix", "lucide", {
Check: {
lucide: "Check",
radix: "CheckIcon",
},
})
).toMatchInlineSnapshot(`
"import { Check } from "lucide-react";
export function Component() {
return <Check />
}"
`)
})
it("should handle icons used as prop references", async () => {
const input = `
import { CheckIcon } from "@radix-ui/react-icons"
export function Component() {
return <Button icon={CheckIcon} />
}`
expect(
await migrateIconsFile(input, "radix", "lucide", {
Check: {
lucide: "Check",
radix: "CheckIcon",
},
})
).toMatchInlineSnapshot(`
"import { Check } from "lucide-react";
export function Component() {
return <Button icon={Check} />
}"
`)
})
})
describe("migrateIconsFile (cross-library)", () => {
it("should migrate from lucide to tabler", async () => {
const input = `
import { Check, ChevronDown } from "lucide-react"
export function Component() {
return (
<div>
<Check className="size-4" />
<ChevronDown />
</div>
)
}`
expect(await migrateIconsFile(input, "lucide", "tabler", FULL_MAPPING))
.toMatchInlineSnapshot(`
"import { IconCheck, IconChevronDown } from "@tabler/icons-react";
export function Component() {
return (
<div>
<IconCheck className="size-4" />
<IconChevronDown />
</div>
)
}"
`)
})
it("should migrate lucide suffixed aliases (CheckIcon)", async () => {
const input = `
import { CheckIcon } from "lucide-react"
export function Component() {
return <CheckIcon className="size-4" />
}`
expect(await migrateIconsFile(input, "lucide", "remixicon", FULL_MAPPING))
.toMatchInlineSnapshot(`
"import { RiCheckLine } from "@remixicon/react";
export function Component() {
return <RiCheckLine className="size-4" />
}"
`)
})
it("should migrate from lucide to hugeicons (wrapper target)", async () => {
const input = `
import { Check, ChevronDown } from "lucide-react"
export function Component() {
return (
<div>
<Check className="size-4" />
<ChevronDown />
</div>
)
}`
expect(await migrateIconsFile(input, "lucide", "hugeicons", FULL_MAPPING))
.toMatchInlineSnapshot(`
"import { HugeiconsIcon } from "@hugeicons/react";
import { Tick02Icon, ArrowDown01Icon } from "@hugeicons/core-free-icons";
export function Component() {
return (
<div>
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="size-4" />
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} />
</div>
)
}"
`)
})
it("should migrate from hugeicons to lucide (wrapper source)", async () => {
const input = `
import { HugeiconsIcon } from "@hugeicons/react"
import { Tick02Icon, ArrowDown01Icon } from "@hugeicons/core-free-icons"
export function Component() {
return (
<div>
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="size-4" />
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={1.5} />
</div>
)
}`
expect(await migrateIconsFile(input, "hugeicons", "lucide", FULL_MAPPING))
.toMatchInlineSnapshot(`
"import { Check, ChevronDown } from "lucide-react";
export function Component() {
return (
<div>
<Check className="size-4" />
<ChevronDown strokeWidth={1.5} />
</div>
)
}"
`)
})
it("should migrate from lucide to phosphor with default props", async () => {
const input = `
import { Check } from "lucide-react"
export function Component() {
return <Check className="size-4" strokeWidth={3} />
}`
expect(await migrateIconsFile(input, "lucide", "phosphor", FULL_MAPPING))
.toMatchInlineSnapshot(`
"import { CheckIcon } from "@phosphor-icons/react";
export function Component() {
return <CheckIcon className="size-4" strokeWidth={3} />
}"
`)
})
it("should migrate from phosphor to lucide stripping template defaults", async () => {
const input = `
import { CheckIcon, CaretDownIcon } from "@phosphor-icons/react"
export function Component() {
return (
<div>
<CheckIcon strokeWidth={2} className="size-4" />
<CaretDownIcon strokeWidth={3} />
</div>
)
}`
expect(await migrateIconsFile(input, "phosphor", "lucide", FULL_MAPPING))
.toMatchInlineSnapshot(`
"import { Check, ChevronDown } from "lucide-react";
export function Component() {
return (
<div>
<Check className="size-4" />
<ChevronDown strokeWidth={3} />
</div>
)
}"
`)
})
it("should migrate from radix to phosphor via the canonical name", async () => {
const input = `
import { CheckIcon } from "@radix-ui/react-icons"
export function Component() {
return <CheckIcon />
}`
expect(await migrateIconsFile(input, "radix", "phosphor", FULL_MAPPING))
.toMatchInlineSnapshot(`
"import { CheckIcon } from "@phosphor-icons/react";
export function Component() {
return <CheckIcon strokeWidth={2} />
}"
`)
})
it("should ignore type-only imports", async () => {
const input = `
import { Check, type LucideIcon } from "lucide-react"
export function Component({ icon }: { icon: LucideIcon }) {
return <Check />
}`
expect(
await migrateIconsFileWithReport(input, "lucide", "tabler", FULL_MAPPING)
).toMatchInlineSnapshot(`
{
"content": "import { type LucideIcon } from "lucide-react"
import { IconCheck } from "@tabler/icons-react";
export function Component({ icon }: { icon: LucideIcon }) {
return <IconCheck />
}",
"skipped": [],
}
`)
})
it("should report skipped icons", async () => {
const input = `
import { Check, Banana } from "lucide-react"
export function Component() {
return (
<div>
<Check>
<title>Done</title>
</Check>
<Banana />
</div>
)
}`
const result = await migrateIconsFileWithReport(
input,
"lucide",
"hugeicons",
FULL_MAPPING
)
expect(result.skipped).toMatchInlineSnapshot(`
[
{
"icon": "Check",
"reason": "cannot be wrapped in <HugeiconsIcon />",
},
{
"icon": "Banana",
"reason": "no HugeIcons equivalent found",
},
]
`)
expect(result.content).toMatchInlineSnapshot(`
"import { Check, Banana } from "lucide-react"
export function Component() {
return (
<div>
<Check>
<title>Done</title>
</Check>
<Banana />
</div>
)
}"
`)
})
})

View File

@@ -0,0 +1,779 @@
import { randomBytes } from "crypto"
import { promises as fs } from "fs"
import { tmpdir } from "os"
import path from "path"
import { iconLibraries } from "@/src/icons/libraries"
import {
getIconModuleSpecifier,
parseImportTemplate,
parseUsageTemplate,
type ParsedUsage,
} from "@/src/icons/templates"
import { getRegistryIcons } from "@/src/registry/api"
import { iconsSchema } from "@/src/schema"
import { Config } from "@/src/utils/get-config"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { updateDependencies } from "@/src/utils/updaters/update-dependencies"
import fg from "fast-glob"
import fsExtra from "fs-extra"
import prompts from "prompts"
import {
JsxOpeningElement,
JsxSelfClosingElement,
Project,
ScriptKind,
SourceFile,
SyntaxKind,
} from "ts-morph"
import { z } from "zod"
// Radix is a legacy icon library: still migratable (in both directions, with
// coverage limited to the legacy mapping entries) but not offered to new
// projects, so it lives here instead of in `iconLibraries`.
export const MIGRATION_ICON_LIBRARIES = {
...iconLibraries,
radix: {
name: "radix",
title: "Radix Icons",
packages: ["@radix-ui/react-icons"],
import: "import { ICON } from '@radix-ui/react-icons'",
usage: "<ICON />",
export: "@radix-ui/react-icons",
},
} as const
export type MigrationIconLibraryName = keyof typeof MIGRATION_ICON_LIBRARIES
type IconsMapping = z.infer<typeof iconsSchema>
export interface SkippedIcon {
icon: string
reason: string
}
export async function migrateIcons(
config: Config,
options: {
from?: string
to?: string
path?: string
yes?: boolean
} = {}
) {
// Determine files to migrate.
let files: string[]
let basePath: string
if (options.path) {
// User provided a path/glob.
basePath = config.resolvedPaths.cwd
const isGlob = options.path.includes("*")
if (isGlob) {
files = await fg(options.path, {
cwd: basePath,
onlyFiles: true,
ignore: ["**/node_modules/**"],
})
} else {
const fullPath = path.resolve(basePath, options.path)
const stat = await fs.stat(fullPath).catch(() => null)
if (!stat) {
throw new Error(`File not found: ${options.path}`)
}
if (stat.isDirectory()) {
basePath = fullPath
files = await fg("**/*.{js,ts,jsx,tsx}", {
cwd: basePath,
onlyFiles: true,
ignore: ["**/node_modules/**"],
})
} else if (stat.isFile()) {
files = [options.path]
} else {
throw new Error(`Unsupported path type: ${options.path}`)
}
}
if (files.length === 0) {
throw new Error(`No files found matching: ${options.path}`)
}
} else {
if (!config.resolvedPaths.ui) {
throw new Error(
"We could not find a valid `ui` path in your `components.json` file. Please ensure you have a valid `ui` path in your `components.json` file."
)
}
basePath = config.resolvedPaths.ui
files = await fg("**/*.{js,ts,jsx,tsx}", {
cwd: basePath,
})
}
const registryIcons = await getRegistryIcons()
if (Object.keys(registryIcons).length === 0) {
throw new Error("Something went wrong fetching the registry icons.")
}
const libraryChoices = Object.entries(MIGRATION_ICON_LIBRARIES).map(
([name, iconLibrary]) => ({
title: iconLibrary.title,
value: name,
})
)
for (const libraryName of [options.from, options.to]) {
if (libraryName && !(libraryName in MIGRATION_ICON_LIBRARIES)) {
throw new Error(
`Unknown icon library: ${libraryName}. Available libraries: ${Object.keys(
MIGRATION_ICON_LIBRARIES
).join(", ")}.`
)
}
}
let sourceLibraryName = options.from
let targetLibraryName = options.to
if (!sourceLibraryName || !targetLibraryName) {
const currentLibraryIndex = libraryChoices.findIndex(
(choice) => choice.value === config.iconLibrary
)
const migrateOptions = await prompts([
{
type: sourceLibraryName ? null : "select",
name: "sourceLibrary",
message: `Which icon library would you like to ${highlighter.info(
"migrate from"
)}?`,
choices: libraryChoices,
initial: currentLibraryIndex === -1 ? 0 : currentLibraryIndex,
},
{
type: targetLibraryName ? null : "select",
name: "targetLibrary",
message: `Which icon library would you like to ${highlighter.info(
"migrate to"
)}?`,
choices: libraryChoices,
},
])
sourceLibraryName = sourceLibraryName ?? migrateOptions.sourceLibrary
targetLibraryName = targetLibraryName ?? migrateOptions.targetLibrary
}
if (!sourceLibraryName || !targetLibraryName) {
logger.info("Migration cancelled.")
process.exit(0)
}
if (sourceLibraryName === targetLibraryName) {
throw new Error(
"You cannot migrate to the same icon library. Please choose a different icon library."
)
}
const sourceLibrary =
MIGRATION_ICON_LIBRARIES[sourceLibraryName as MigrationIconLibraryName]
const targetLibrary =
MIGRATION_ICON_LIBRARIES[targetLibraryName as MigrationIconLibraryName]
if (!options.yes) {
const relativePath = options.path
? options.path
: `./${path.relative(config.resolvedPaths.cwd, basePath)}`
const { confirm } = await prompts({
type: "confirm",
name: "confirm",
initial: true,
message: `We will migrate ${highlighter.info(
files.length
)} files in ${highlighter.info(relativePath)} from ${highlighter.info(
sourceLibrary.title
)} to ${highlighter.info(targetLibrary.title)}. Continue?`,
})
if (!confirm) {
logger.info("Migration cancelled.")
process.exit(0)
}
}
await updateDependencies([...targetLibrary.packages], [], config, {
silent: false,
})
const migrationSpinner = spinner(`Migrating icons...`)?.start()
const skippedIcons = new Map<string, string>()
await Promise.all(
files.map(async (file) => {
migrationSpinner.text = `Migrating ${file}...`
const filePath = path.join(basePath, file)
const fileContent = await fs.readFile(filePath, "utf-8")
const { content, skipped } = await migrateIconsFileWithReport(
fileContent,
sourceLibraryName as MigrationIconLibraryName,
targetLibraryName as MigrationIconLibraryName,
registryIcons
)
for (const skippedIcon of skipped) {
if (!skippedIcons.has(skippedIcon.icon)) {
skippedIcons.set(skippedIcon.icon, skippedIcon.reason)
}
}
await fs.writeFile(filePath, content)
})
)
migrationSpinner.succeed("Migration complete.")
// Keep components.json in sync so future `shadcn add` installs use the
// new icon library. Skip for scoped runs: a partial migration should not
// change project-wide config.
if (!options.path) {
await updateConfigIconLibrary(config, targetLibraryName)
}
if (skippedIcons.size > 0) {
logger.break()
logger.warn(
`Skipped ${skippedIcons.size} icon${
skippedIcons.size === 1 ? "" : "s"
}. These were left untouched:`
)
for (const [icon, reason] of Array.from(skippedIcons)) {
logger.warn(` - ${icon}: ${reason}`)
}
}
}
async function updateConfigIconLibrary(config: Config, iconLibrary: string) {
const targetPath = path.resolve(config.resolvedPaths.cwd, "components.json")
if (!fsExtra.existsSync(targetPath)) {
return
}
const rawConfig = await fsExtra.readJson(targetPath)
if (rawConfig.iconLibrary === iconLibrary) {
return
}
rawConfig.iconLibrary = iconLibrary
await fsExtra.writeJson(targetPath, rawConfig, { spaces: 2 })
logger.info(
`Updated ${highlighter.info("iconLibrary")} in components.json to ${highlighter.info(
iconLibrary
)}.`
)
}
export async function migrateIconsFile(
content: string,
sourceLibrary: MigrationIconLibraryName,
targetLibrary: MigrationIconLibraryName,
iconsMapping: IconsMapping
) {
const result = await migrateIconsFileWithReport(
content,
sourceLibrary,
targetLibrary,
iconsMapping
)
return result.content
}
export async function migrateIconsFileWithReport(
content: string,
sourceLibraryName: MigrationIconLibraryName,
targetLibraryName: MigrationIconLibraryName,
iconsMapping: IconsMapping
): Promise<{ content: string; skipped: SkippedIcon[] }> {
const sourceLibrary = MIGRATION_ICON_LIBRARIES[sourceLibraryName]
const targetLibrary = MIGRATION_ICON_LIBRARIES[targetLibraryName]
const sourceIconModule = getIconModuleSpecifier(sourceLibrary.import)
const sourceUsage = parseUsageTemplate(sourceLibrary.usage)
const targetUsage = parseUsageTemplate(targetLibrary.usage)
if (!sourceIconModule) {
throw new Error(
`Could not resolve the icon import for ${sourceLibrary.title}.`
)
}
const reverseIndex = buildReverseIconIndex(iconsMapping, sourceLibraryName)
const skipped: SkippedIcon[] = []
const dir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-"))
const project = new Project({
compilerOptions: {},
})
const tempFile = path.join(
dir,
`shadcn-icons-${randomBytes(4).toString("hex")}.tsx`
)
const sourceFile = project.createSourceFile(tempFile, content, {
scriptKind: ScriptKind.TSX,
})
let migratedIcons: string[] = []
for (const importDeclaration of sourceFile.getImportDeclarations() ?? []) {
if (
importDeclaration.getModuleSpecifier()?.getLiteralValue() !==
sourceIconModule ||
importDeclaration.isTypeOnly()
) {
continue
}
for (const specifier of [...(importDeclaration.getNamedImports() ?? [])]) {
// Type imports (e.g. LucideIcon) are not icons.
if (specifier.isTypeOnly()) {
continue
}
const importedName = specifier.getName()
const localName = specifier.getAliasNode()?.getText() ?? importedName
const canonical = reverseIndex.get(importedName)
const targetIcon = canonical
? iconsMapping[canonical]?.[targetLibraryName]
: undefined
if (!targetIcon) {
skipped.push({
icon: importedName,
reason: `no ${targetLibrary.title} equivalent found`,
})
continue
}
const usages = findIconUsages(sourceFile, localName, sourceUsage)
if (usages.complex) {
skipped.push({
icon: importedName,
reason: "unsupported usage (e.g. referenced outside JSX)",
})
continue
}
const targetIsWrapper = targetUsage.componentName !== "ICON"
if (targetIsWrapper && (usages.references > 0 || usages.paired > 0)) {
skipped.push({
icon: importedName,
reason: `cannot be wrapped in <${targetUsage.componentName} />`,
})
continue
}
specifier.remove()
rewriteIconUsages(
sourceFile,
localName,
targetIcon,
sourceUsage,
targetUsage
)
if (!migratedIcons.includes(targetIcon)) {
migratedIcons.push(targetIcon)
}
}
// If the named import is empty, remove the import declaration.
if (
importDeclaration.getNamedImports()?.length === 0 &&
!importDeclaration.getDefaultImport() &&
!importDeclaration.getNamespaceImport()
) {
importDeclaration.remove()
}
}
// If the source library renders through a wrapper component (e.g.
// HugeiconsIcon) and no usages remain, remove the wrapper import too.
if (sourceUsage.componentName !== "ICON" && migratedIcons.length > 0) {
removeUnusedWrapperImport(sourceFile, sourceLibrary.import, sourceUsage)
}
if (migratedIcons.length > 0) {
for (const parsedImport of parseImportTemplate(targetLibrary.import)) {
const namedImports = parsedImport.namedImports.flatMap((name) =>
name === "ICON" ? migratedIcons : [name]
)
// Non-icon imports (e.g. the wrapper component) may already exist.
const alreadyImported =
!parsedImport.namedImports.includes("ICON") &&
sourceFile
.getImportDeclarations()
.some(
(declaration) =>
declaration.getModuleSpecifier()?.getLiteralValue() ===
parsedImport.moduleSpecifier &&
declaration
.getNamedImports()
.some((named) => namedImports.includes(named.getName()))
)
if (alreadyImported) {
continue
}
sourceFile.addImportDeclaration({
moduleSpecifier: parsedImport.moduleSpecifier,
namedImports: namedImports.map((name) => ({
name,
})),
})
}
}
return { content: sourceFile.getText(), skipped }
}
function buildReverseIconIndex(
iconsMapping: IconsMapping,
sourceLibraryName: MigrationIconLibraryName
) {
const index = new Map<string, string>()
for (const [canonical, entry] of Object.entries(iconsMapping)) {
const iconName = entry[sourceLibraryName]
if (iconName && !index.has(iconName)) {
index.set(iconName, canonical)
}
}
// Lucide exports every icon under two names (e.g. Check and CheckIcon).
// The mapping only records one of them, so index both.
if (sourceLibraryName === "lucide") {
for (const [iconName, canonical] of Array.from(index)) {
const alias = iconName.endsWith("Icon")
? iconName.slice(0, -"Icon".length)
: `${iconName}Icon`
if (alias && !index.has(alias)) {
index.set(alias, canonical)
}
}
}
return index
}
interface IconUsages {
selfClosing: number
paired: number
references: number
complex: boolean
}
function findIconUsages(
sourceFile: SourceFile,
localName: string,
sourceUsage: ParsedUsage
): IconUsages {
const usages: IconUsages = {
selfClosing: 0,
paired: 0,
references: 0,
complex: false,
}
for (const identifier of sourceFile.getDescendantsOfKind(
SyntaxKind.Identifier
)) {
if (identifier.getText() !== localName) {
continue
}
if (identifier.getFirstAncestorByKind(SyntaxKind.ImportDeclaration)) {
continue
}
const parent = identifier.getParent()
const parentKind = parent?.getKind()
if (sourceUsage.componentName === "ICON") {
if (parentKind === SyntaxKind.JsxSelfClosingElement) {
usages.selfClosing++
} else if (parentKind === SyntaxKind.JsxOpeningElement) {
usages.paired++
} else if (parentKind === SyntaxKind.JsxClosingElement) {
// Counted via the opening element.
} else if (
parentKind === SyntaxKind.JsxExpression &&
parent?.getParent()?.getKind() === SyntaxKind.JsxAttribute
) {
usages.references++
} else {
usages.complex = true
}
continue
}
// Wrapper-style source (e.g. <HugeiconsIcon icon={ICON} />): the icon
// only ever appears inside the wrapper's icon attribute.
const jsxAttribute = identifier.getFirstAncestorByKind(
SyntaxKind.JsxAttribute
)
const wrapperElement = identifier.getFirstAncestorByKind(
SyntaxKind.JsxSelfClosingElement
)
if (
parentKind === SyntaxKind.JsxExpression &&
jsxAttribute?.getNameNode().getText() === sourceUsage.iconAttribute &&
wrapperElement?.getTagNameNode()?.getText() === sourceUsage.componentName
) {
usages.selfClosing++
} else {
usages.complex = true
}
}
return usages
}
function rewriteIconUsages(
sourceFile: SourceFile,
localName: string,
targetIcon: string,
sourceUsage: ParsedUsage,
targetUsage: ParsedUsage
) {
if (sourceUsage.componentName === "ICON") {
// Direct-style source: the icon is the JSX tag.
for (const element of sourceFile.getDescendantsOfKind(
SyntaxKind.JsxSelfClosingElement
)) {
if (element.getTagNameNode()?.getText() !== localName) {
continue
}
rewriteElement(element, targetIcon, sourceUsage, targetUsage)
}
for (const element of sourceFile.getDescendantsOfKind(
SyntaxKind.JsxElement
)) {
const openingElement = element.getOpeningElement()
if (openingElement.getTagNameNode()?.getText() !== localName) {
continue
}
// Wrapper targets are filtered out before we get here.
applyUsageAttributes(openingElement, sourceUsage, targetUsage)
openingElement.getTagNameNode()?.replaceWithText(targetIcon)
element.getClosingElement().getTagNameNode()?.replaceWithText(targetIcon)
}
for (const identifier of sourceFile.getDescendantsOfKind(
SyntaxKind.Identifier
)) {
if (identifier.getText() !== localName) {
continue
}
if (identifier.getFirstAncestorByKind(SyntaxKind.ImportDeclaration)) {
continue
}
const parent = identifier.getParent()
if (
parent?.getKind() === SyntaxKind.JsxExpression &&
parent?.getParent()?.getKind() === SyntaxKind.JsxAttribute
) {
identifier.replaceWithText(targetIcon)
}
}
return
}
// Wrapper-style source: rewrite each wrapper element using this icon.
for (const element of sourceFile.getDescendantsOfKind(
SyntaxKind.JsxSelfClosingElement
)) {
if (
element.getTagNameNode()?.getText() !== sourceUsage.componentName ||
!sourceUsage.iconAttribute
) {
continue
}
const iconAttribute = element.getAttribute(sourceUsage.iconAttribute)
if (!iconAttribute || iconAttribute.getKind() !== SyntaxKind.JsxAttribute) {
continue
}
const iconExpression = iconAttribute
.asKindOrThrow(SyntaxKind.JsxAttribute)
.getInitializer()
?.getText()
if (iconExpression !== `{${localName}}`) {
continue
}
rewriteElement(element, targetIcon, sourceUsage, targetUsage, {
excludeAttribute: sourceUsage.iconAttribute,
})
}
}
function rewriteElement(
element: JsxSelfClosingElement,
targetIcon: string,
sourceUsage: ParsedUsage,
targetUsage: ParsedUsage,
options: { excludeAttribute?: string } = {}
) {
const sourceIsPlain =
sourceUsage.componentName === "ICON" &&
Object.keys(sourceUsage.attributes).length === 0
const targetIsPlain =
targetUsage.componentName === "ICON" &&
Object.keys(targetUsage.attributes).length === 0
// Plain tag to plain tag (e.g. lucide -> radix): rename in place to
// preserve the original formatting.
if (sourceIsPlain && targetIsPlain) {
element.getTagNameNode()?.replaceWithText(targetIcon)
return
}
const userAttributes = element
.getAttributes()
.filter((attribute) => {
if (attribute.getKind() !== SyntaxKind.JsxAttribute) {
return true
}
const jsxAttribute = attribute.asKindOrThrow(SyntaxKind.JsxAttribute)
const name = jsxAttribute.getNameNode().getText()
if (name === options.excludeAttribute) {
return false
}
// Drop defaults the source library's template added at install time,
// but only on an exact match (a customized value is user intent).
return (
sourceUsage.attributes[name] !==
(jsxAttribute.getInitializer()?.getText() ?? "")
)
})
.map((attribute) => attribute.getText())
const userAttributeNames = new Set(
element
.getAttributes()
.filter((attribute) => attribute.getKind() === SyntaxKind.JsxAttribute)
.map((attribute) =>
attribute.asKindOrThrow(SyntaxKind.JsxAttribute).getNameNode().getText()
)
)
const defaultAttributes = Object.entries(targetUsage.attributes)
.filter(([name]) => !userAttributeNames.has(name))
.map(([name, value]) => `${name}=${value}`)
const parts =
targetUsage.componentName === "ICON"
? [targetIcon, ...defaultAttributes, ...userAttributes]
: [
targetUsage.componentName,
`${targetUsage.iconAttribute}={${targetIcon}}`,
...defaultAttributes,
...userAttributes,
]
element.replaceWithText(`<${parts.join(" ")} />`)
}
function applyUsageAttributes(
element: JsxOpeningElement | JsxSelfClosingElement,
sourceUsage: ParsedUsage,
targetUsage: ParsedUsage
) {
for (const [name, value] of Object.entries(sourceUsage.attributes)) {
const attribute = element.getAttribute(name)
if (
attribute?.getKind() === SyntaxKind.JsxAttribute &&
(attribute
.asKindOrThrow(SyntaxKind.JsxAttribute)
.getInitializer()
?.getText() ?? "") === value
) {
attribute.remove()
}
}
for (const [name, value] of Object.entries(targetUsage.attributes)) {
if (!element.getAttribute(name)) {
element.addAttribute({ name, initializer: value })
}
}
}
function removeUnusedWrapperImport(
sourceFile: SourceFile,
importTemplate: string,
sourceUsage: ParsedUsage
) {
const wrapperName = sourceUsage.componentName
const wrapperStillUsed =
sourceFile
.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)
.some((element) => element.getTagNameNode()?.getText() === wrapperName) ||
sourceFile
.getDescendantsOfKind(SyntaxKind.JsxOpeningElement)
.some((element) => element.getTagNameNode()?.getText() === wrapperName)
if (wrapperStillUsed) {
return
}
const wrapperModules = parseImportTemplate(importTemplate)
.filter((parsed) => !parsed.namedImports.includes("ICON"))
.map((parsed) => parsed.moduleSpecifier)
for (const importDeclaration of sourceFile.getImportDeclarations() ?? []) {
if (
!wrapperModules.includes(
importDeclaration.getModuleSpecifier()?.getLiteralValue() ?? ""
)
) {
continue
}
for (const specifier of [...(importDeclaration.getNamedImports() ?? [])]) {
if (specifier.getName() === wrapperName) {
specifier.remove()
}
}
if (
importDeclaration.getNamedImports()?.length === 0 &&
!importDeclaration.getDefaultImport() &&
!importDeclaration.getNamespaceImport()
) {
importDeclaration.remove()
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,458 @@
import { promises as fs } from "fs"
import path from "path"
import { Config } from "@/src/utils/get-config"
import { getPackageInfo } from "@/src/utils/get-package-info"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { updateDependencies } from "@/src/utils/updaters/update-dependencies"
import fg from "fast-glob"
import prompts from "prompts"
function toPascalCase(str: string): string {
return str
.split("-")
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("")
}
function processNamedImports(
namedImports: string,
isTypeOnly: boolean,
imports: Array<{ name: string; alias?: string; isType?: boolean }>,
packageName: string
) {
// Clean up multi-line imports.
// Remove comments and whitespace.
const cleanedImports = namedImports
.replace(/\/\/.*$/gm, "")
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\s+/g, " ")
.trim()
const namedImportList = cleanedImports
.split(",")
.map((importItem) => importItem.trim())
.filter(Boolean)
for (const importItem of namedImportList) {
const inlineTypeMatch = importItem.match(/^type\s+(\w+)(?:\s+as\s+(\w+))?$/)
const aliasMatch = importItem.match(/^(\w+)\s+as\s+(\w+)$/)
if (inlineTypeMatch) {
// Inline type: "type DialogProps" or "type DialogProps as Props"
const importName = inlineTypeMatch[1]
const importAlias = inlineTypeMatch[2]
if (packageName === "slot" && importName === "Slot" && !importAlias) {
imports.push({
name: "Slot",
alias: "SlotPrimitive",
isType: true,
})
} else {
imports.push({
name: importName,
alias: importAlias,
isType: true,
})
}
} else if (aliasMatch) {
// Regular import with alias: "Root as DialogRoot"
const importName = aliasMatch[1]
const importAlias = aliasMatch[2]
if (
packageName === "slot" &&
importName === "Slot" &&
importAlias === "Slot"
) {
imports.push({
name: "Slot",
alias: "SlotPrimitive",
isType: isTypeOnly,
})
} else {
imports.push({
name: importName,
alias: importAlias,
isType: isTypeOnly,
})
}
} else {
// Simple import: "Root"
// Special handling for Slot: always alias it as SlotPrimitive
if (packageName === "slot" && importItem === "Slot") {
imports.push({
name: "Slot",
alias: "SlotPrimitive",
isType: isTypeOnly,
})
} else {
imports.push({
name: importItem,
isType: isTypeOnly,
})
}
}
}
}
export async function migrateRadix(
config: Config,
options: { yes?: boolean; path?: string } = {}
) {
// Determine files to migrate.
let files: string[]
let basePath: string
if (options.path) {
// User provided a path/glob.
basePath = config.resolvedPaths.cwd
const isGlob = options.path.includes("*")
if (isGlob) {
files = await fg(options.path, {
cwd: basePath,
onlyFiles: true,
ignore: ["**/node_modules/**"],
suppressErrors: true,
})
} else {
const fullPath = path.resolve(basePath, options.path)
const stat = await fs.stat(fullPath).catch(() => null)
if (!stat) {
throw new Error(`File not found: ${options.path}`)
}
if (stat.isDirectory()) {
basePath = fullPath
files = await fg("**/*.{js,ts,jsx,tsx}", {
cwd: basePath,
onlyFiles: true,
ignore: ["**/node_modules/**"],
suppressErrors: true,
})
} else if (stat.isFile()) {
files = [options.path]
} else {
throw new Error(`Unsupported path type: ${options.path}`)
}
}
if (files.length === 0) {
throw new Error(`No files found matching: ${options.path}`)
}
} else {
// Default: use ui path from components.json.
if (!config.resolvedPaths.ui) {
throw new Error(
"We could not find a valid `ui` path in your `components.json` file. Please ensure you have a valid `ui` path in your `components.json` file."
)
}
basePath = config.resolvedPaths.ui
files = await fg("**/*.{js,ts,jsx,tsx}", {
cwd: basePath,
onlyFiles: true,
suppressErrors: true,
})
}
// Confirm with user.
if (!options.yes) {
const relativePath = options.path
? options.path
: `./${path.relative(config.resolvedPaths.cwd, basePath)}`
const { confirm } = await prompts({
type: "confirm",
name: "confirm",
initial: true,
message: `We will migrate ${highlighter.info(
files.length
)} file(s) in ${highlighter.info(relativePath)} to ${highlighter.info(
"radix-ui"
)}. Continue?`,
})
if (!confirm) {
logger.info("Migration cancelled.")
process.exit(0)
}
}
const migrationSpinner = spinner(`Migrating imports...`)?.start()
const foundPackages = new Set<string>()
await Promise.all(
files.map(async (file) => {
migrationSpinner.text = `Migrating ${file}...`
const filePath = path.join(basePath, file)
const fileContent = await fs.readFile(filePath, "utf-8")
const { content, replacedPackages } = await migrateRadixFile(fileContent)
// Track which packages we found
replacedPackages.forEach((pkg) => foundPackages.add(pkg))
await fs.writeFile(filePath, content)
})
)
migrationSpinner.succeed("Migrating imports.")
// Update package.json dependencies.
const packageSpinner = spinner(`Updating package.json...`)?.start()
try {
const packageJson = getPackageInfo(config.resolvedPaths.cwd, false)
if (!packageJson) {
packageSpinner.fail("Could not read package.json")
logger.warn(
"Could not update package.json. You may need to manually replace @radix-ui/react-* packages with radix-ui"
)
return
}
const foundPackagesArray = Array.from(foundPackages)
// Add radix-ui if we found any Radix packages.
if (foundPackagesArray.length > 0) {
if (!packageJson.dependencies) {
packageJson.dependencies = {}
}
// Only add radix-ui if it's not already in dependencies.
const hasRadixUi =
packageJson.dependencies?.["radix-ui"] ||
packageJson.devDependencies?.["radix-ui"]
if (!hasRadixUi) {
packageJson.dependencies["radix-ui"] = "latest"
const packageJsonPath = path.join(
config.resolvedPaths.cwd,
"package.json"
)
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2) + "\n"
)
packageSpinner.succeed(`Updated package.json.`)
// Install radix-ui dependency.
await updateDependencies(["radix-ui"], [], config, { silent: false })
} else {
packageSpinner.succeed(`radix-ui already in package.json.`)
}
// Show a message about old packages that can be removed.
logger.info("")
logger.info(
`Migration complete. The following packages may be removed if no longer in use:`
)
logger.info(highlighter.info(foundPackagesArray.join(", ")))
logger.info(`Please review your codebase before removing.`)
} else {
packageSpinner.succeed("No packages found in source files.")
}
} catch (error) {
packageSpinner.fail("Failed to update package.json")
logger.warn(
"You may need to manually replace @radix-ui/react-* packages with radix-ui"
)
}
}
export async function migrateRadixFile(
content: string
): Promise<{ content: string; replacedPackages: string[] }> {
// Enhanced regex to handle type-only imports, but exclude react-icons
// Also capture optional semicolon at the end
const radixImportPattern =
/import\s+(?:(type)\s+)?(?:\*\s+as\s+(\w+)|{([^}]+)})\s+from\s+(["'])@radix-ui\/react-([^"']+)\4(;?)/g
const imports: Array<{ name: string; alias?: string; isType?: boolean }> = []
const linesToRemove: string[] = []
const replacedPackages: string[] = []
let quoteStyle = '"' // Default to double quotes
let hasSemicolon = false // Track if any import had a semicolon
let result = content
let match
// Find all Radix imports
while ((match = radixImportPattern.exec(content)) !== null) {
const [
fullMatch,
typeKeyword,
namespaceAlias,
namedImports,
quote,
packageName,
semicolon,
] = match
// Skip react-icons package and any sub-paths (like react-icons/dist/types)
if (packageName === "icons" || packageName.startsWith("icons/")) {
continue
}
linesToRemove.push(fullMatch)
// Use the quote style and semicolon style from the first import
if (linesToRemove.length === 1) {
quoteStyle = quote
hasSemicolon = semicolon === ";"
}
// Track which package we're replacing
replacedPackages.push(`@radix-ui/react-${packageName}`)
const isTypeOnly = Boolean(typeKeyword)
if (namespaceAlias) {
// Handle namespace imports: import * as DialogPrimitive from "@radix-ui/react-dialog"
const componentName = toPascalCase(packageName)
imports.push({
name: componentName,
alias: namespaceAlias,
isType: isTypeOnly,
})
} else if (namedImports) {
// Handle named imports: import { Root, Trigger } from "@radix-ui/react-dialog"
// or import type { DialogProps } from "@radix-ui/react-dialog"
// or import { type DialogProps, Root } from "@radix-ui/react-dialog"
processNamedImports(namedImports, isTypeOnly, imports, packageName)
}
}
if (imports.length === 0) {
return {
content,
replacedPackages: [],
}
}
// Remove duplicates.
// Considering name, alias, and type status.
const uniqueImports = imports.filter(
(importName, index, self) =>
index ===
self.findIndex(
(i) =>
i.name === importName.name &&
i.alias === importName.alias &&
i.isType === importName.isType
)
)
// Create the unified import with preserved quote style and type annotations
const importList = uniqueImports
.map((imp) => {
const typePrefix = imp.isType ? "type " : ""
return imp.alias
? `${typePrefix}${imp.name} as ${imp.alias}`
: `${typePrefix}${imp.name}`
})
.join(", ")
const unifiedImport = `import { ${importList} } from ${quoteStyle}radix-ui${quoteStyle}${
hasSemicolon ? ";" : ""
}`
// Replace first import with unified import, remove the rest
result = linesToRemove.reduce((acc, line, index) => {
return acc.replace(line, index === 0 ? unifiedImport : "")
}, result)
// Clean up extra blank lines
result = result.replace(/\n\s*\n\s*\n/g, "\n\n")
// Handle special case for Slot usage transformation
// Now that we import { Slot as SlotPrimitive }, we need to:
// 1. Transform: const Comp = asChild ? Slot : [ANYTHING] -> const Comp = asChild ? SlotPrimitive.Slot : [ANYTHING]
// 2. Transform: React.ComponentProps<typeof Slot> -> React.ComponentProps<typeof SlotPrimitive.Slot>
const hasSlotImport = uniqueImports.some(
(imp) => imp.name === "Slot" && imp.alias === "SlotPrimitive"
)
if (hasSlotImport) {
// Find all lines that are NOT import lines to avoid transforming the import statement itself
const lines = result.split("\n")
const transformedLines = lines.map((line) => {
// Skip import lines
if (line.trim().startsWith("import ")) {
return line
}
let transformedLine = line
// Handle all Slot references in one comprehensive pass
// Use placeholders to avoid double replacements
// First, mark specific patterns with placeholders
transformedLine = transformedLine.replace(
/\b(asChild\s*\?\s*)Slot(\s*:)/g,
"$1__SLOT_PLACEHOLDER__$2"
)
transformedLine = transformedLine.replace(
/\bReact\.ComponentProps<typeof\s+Slot>/g,
"React.ComponentProps<typeof __SLOT_PLACEHOLDER__>"
)
transformedLine = transformedLine.replace(
/\bComponentProps<typeof\s+Slot>/g,
"ComponentProps<typeof __SLOT_PLACEHOLDER__>"
)
transformedLine = transformedLine.replace(
/(<\/?)Slot(\s*\/?>)/g,
"$1__SLOT_PLACEHOLDER__$2"
)
// Handle any other standalone Slot usage
transformedLine = transformedLine.replace(
/\bSlot\b/g,
(match, offset, string) => {
// Don't transform if it's inside quotes
const beforeMatch = string.substring(0, offset)
const openQuotes = (beforeMatch.match(/"/g) || []).length
const openSingleQuotes = (beforeMatch.match(/'/g) || []).length
// If we're inside quotes, don't transform
if (openQuotes % 2 !== 0 || openSingleQuotes % 2 !== 0) {
return match
}
return "__SLOT_PLACEHOLDER__"
}
)
// Finally, replace all placeholders with SlotPrimitive.Slot
transformedLine = transformedLine.replace(
/__SLOT_PLACEHOLDER__/g,
"SlotPrimitive.Slot"
)
return transformedLine
})
result = transformedLines.join("\n")
}
// Remove duplicate packages
const uniqueReplacedPackages = Array.from(new Set(replacedPackages))
return {
content: result,
replacedPackages: uniqueReplacedPackages,
}
}

View File

@@ -0,0 +1,125 @@
import { transformDirection } from "@/src/utils/transformers/transform-rtl"
import { describe, expect, it } from "vitest"
describe("migrateRtl", () => {
describe("transformDirection", () => {
it("should transform className string literals", async () => {
const input = `
import * as React from "react"
export function Component() {
return <div className="ml-2 mr-4 text-left">content</div>
}`
const result = await transformDirection(input, true)
expect(result).toContain("ms-2")
expect(result).toContain("me-4")
expect(result).toContain("text-start")
})
it("should transform cn() function arguments", async () => {
const input = `
import * as React from "react"
export function Component() {
return <div className={cn("ml-2 mr-4", true && "pl-2")}>content</div>
}`
const result = await transformDirection(input, true)
expect(result).toContain("ms-2")
expect(result).toContain("me-4")
expect(result).toContain("ps-2")
})
it("should transform cva base classes and variants", async () => {
const input = `
import { cva } from "class-variance-authority"
const buttonVariants = cva("ml-2 mr-4", {
variants: {
size: {
default: "pl-4 pr-4",
sm: "pl-2 pr-2",
},
},
})`
const result = await transformDirection(input, true)
expect(result).toContain("ms-2")
expect(result).toContain("me-4")
expect(result).toContain("ps-4")
expect(result).toContain("pe-4")
expect(result).toContain("ps-2")
expect(result).toContain("pe-2")
})
it("should not transform when rtl is false", async () => {
const input = `
import * as React from "react"
export function Component() {
return <div className="ml-2 mr-4 text-left">content</div>
}`
const result = await transformDirection(input, false)
expect(result).toContain("ml-2")
expect(result).toContain("mr-4")
expect(result).toContain("text-left")
expect(result).not.toContain("ms-2")
})
it("should skip classes with rtl: or ltr: prefixes", async () => {
const input = `
import * as React from "react"
export function Component() {
return <div className="ml-2 rtl:mr-2 ltr:pl-4">content</div>
}`
const result = await transformDirection(input, true)
expect(result).toContain("ms-2")
expect(result).toContain("rtl:mr-2")
expect(result).toContain("ltr:pl-4")
})
it("should add rtl: variants for translate-x classes", async () => {
const input = `
import * as React from "react"
export function Component() {
return <div className="-translate-x-1/2">content</div>
}`
const result = await transformDirection(input, true)
expect(result).toContain("-translate-x-1/2")
expect(result).toContain("rtl:translate-x-1/2")
})
it("should add rtl:space-x-reverse for space-x classes", async () => {
const input = `
import * as React from "react"
export function Component() {
return <div className="space-x-4">content</div>
}`
const result = await transformDirection(input, true)
expect(result).toContain("space-x-4")
expect(result).toContain("rtl:space-x-reverse")
})
it("should transform cn-rtl-flip marker to rtl:rotate-180", async () => {
const input = `
import * as React from "react"
export function Component() {
return <Icon className="cn-rtl-flip size-4" />
}`
const result = await transformDirection(input, true)
expect(result).toContain("rtl:rotate-180")
expect(result).toContain("size-4")
expect(result).not.toContain("cn-rtl-flip")
})
})
})

View File

@@ -0,0 +1,163 @@
import { promises as fs } from "fs"
import path from "path"
import { SHADCN_URL } from "@/src/registry/constants"
import { Config } from "@/src/utils/get-config"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { transformDirection } from "@/src/utils/transformers/transform-rtl"
import fg from "fast-glob"
import prompts from "prompts"
// Files that may need manual RTL adjustments.
const FILES_NEEDING_MANUAL_REVIEW = [
"sidebar.tsx",
"pagination.tsx",
"calendar.tsx",
]
const RTL_DOCS_URL = `${SHADCN_URL}/docs/rtl#manual-migration-optional`
export async function migrateRtl(
config: Config,
options: { yes?: boolean; path?: string } = {}
) {
// Determine files to migrate.
let files: string[]
let basePath: string
if (options.path) {
// User provided a path/glob.
basePath = config.resolvedPaths.cwd
const isGlob = options.path.includes("*")
if (isGlob) {
files = await fg(options.path, {
cwd: basePath,
onlyFiles: true,
ignore: ["**/node_modules/**"],
suppressErrors: true,
})
} else {
const fullPath = path.resolve(basePath, options.path)
const stat = await fs.stat(fullPath).catch(() => null)
if (!stat) {
throw new Error(`File not found: ${options.path}`)
}
if (stat.isDirectory()) {
basePath = fullPath
files = await fg("**/*.{js,ts,jsx,tsx}", {
cwd: basePath,
onlyFiles: true,
ignore: ["**/node_modules/**"],
suppressErrors: true,
})
} else if (stat.isFile()) {
files = [options.path]
} else {
throw new Error(`Unsupported path type: ${options.path}`)
}
}
if (files.length === 0) {
throw new Error(`No files found matching: ${options.path}`)
}
} else {
// Default: use ui path from components.json.
if (!config.resolvedPaths.ui) {
throw new Error(
"Could not find a valid `ui` path in your `components.json`. Please provide a path or glob pattern."
)
}
basePath = config.resolvedPaths.ui
files = await fg("**/*.{js,ts,jsx,tsx}", {
cwd: basePath,
onlyFiles: true,
suppressErrors: true,
})
}
// Confirm with user.
if (!options.yes) {
const relativePath = options.path
? options.path
: `./${path.relative(config.resolvedPaths.cwd, basePath)}`
const { confirm } = await prompts({
type: "confirm",
name: "confirm",
initial: true,
message: `We will migrate ${highlighter.info(
files.length
)} file(s) in ${highlighter.info(relativePath)} to RTL. Continue?`,
})
if (!confirm) {
logger.info("Migration cancelled.")
process.exit(0)
}
}
// Update components.json to set rtl: true.
const configSpinner = spinner("Updating components.json...").start()
try {
const configPath = path.resolve(config.resolvedPaths.cwd, "components.json")
const existingConfig = JSON.parse(await fs.readFile(configPath, "utf-8"))
existingConfig.rtl = true
await fs.writeFile(
configPath,
JSON.stringify(existingConfig, null, 2) + "\n"
)
configSpinner.succeed("Updated components.json.")
} catch {
configSpinner.fail("Failed to update components.json.")
throw new Error(
"Could not update components.json. Please manually set `rtl: true`."
)
}
// Transform files.
const migrationSpinner = spinner("Migrating files to RTL...").start()
let transformedCount = 0
const filesNeedingReview: string[] = []
await Promise.all(
files.map(async (file) => {
migrationSpinner.text = `Migrating ${file}...`
const filePath = path.join(basePath, file)
const content = await fs.readFile(filePath, "utf-8")
const transformed = await transformDirection(content, true)
// Only write if content changed.
if (transformed !== content) {
await fs.writeFile(filePath, transformed)
transformedCount++
}
// Check if this file needs manual review.
const fileName = path.basename(file)
if (FILES_NEEDING_MANUAL_REVIEW.includes(fileName)) {
filesNeedingReview.push(file)
}
})
)
migrationSpinner.succeed(
`Migration complete. ${transformedCount} file(s) transformed.`
)
// Show message for files that need manual review.
if (filesNeedingReview.length > 0) {
logger.break()
logger.warn("The following components may need manual RTL adjustments:")
for (const file of filesNeedingReview) {
logger.info(` - ${file}`)
}
logger.break()
logger.info(`See ${highlighter.info(RTL_DOCS_URL)} for more information.`)
}
}

View File

@@ -0,0 +1,74 @@
import path from "path"
import { addOptionsSchema } from "@/src/commands/add"
import { SHADCN_URL } from "@/src/registry/constants"
import * as ERRORS from "@/src/utils/errors"
import { getConfig } from "@/src/utils/get-config"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import fs from "fs-extra"
import { z } from "zod"
export async function preFlightAdd(options: z.infer<typeof addOptionsSchema>) {
const errors: Record<string, boolean> = {}
// Ensure target directory exists.
// Check for empty project. We assume if no package.json exists, the project is empty.
if (
!fs.existsSync(options.cwd) ||
!fs.existsSync(path.resolve(options.cwd, "package.json"))
) {
errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT] = true
return {
errors,
config: null,
}
}
// Check for existing components.json file.
if (!fs.existsSync(path.resolve(options.cwd, "components.json"))) {
// Check if we're in a monorepo root.
if (await isMonorepoRoot(options.cwd)) {
const targets = await getMonorepoTargets(options.cwd)
if (targets.length > 0) {
formatMonorepoMessage("add [component]", targets)
process.exit(1)
}
}
errors[ERRORS.MISSING_CONFIG] = true
return {
errors,
config: null,
}
}
try {
const config = await getConfig(options.cwd)
return {
errors,
config: config!,
}
} catch (error) {
logger.break()
logger.error(
`An invalid ${highlighter.info(
"components.json"
)} file was found at ${highlighter.info(
options.cwd
)}.\nBefore you can add components, you must create a valid ${highlighter.info(
"components.json"
)} file by running the ${highlighter.info("init")} command.`
)
logger.error(
`Learn more at ${highlighter.info(`${SHADCN_URL}/docs/components-json`)}.`
)
logger.break()
process.exit(1)
}
}

View File

@@ -0,0 +1,82 @@
import path from "path"
import { SHADCN_URL } from "@/src/registry/constants"
import * as ERRORS from "@/src/utils/errors"
import { getConfig } from "@/src/utils/get-config"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { getProjectInfo } from "@/src/utils/get-project-info"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import fs from "fs-extra"
export async function preFlightApply(options: { cwd: string }) {
const errors: Record<string, boolean> = {}
if (
!fs.existsSync(options.cwd) ||
!fs.existsSync(path.resolve(options.cwd, "package.json"))
) {
errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT] = true
return {
errors,
config: null,
}
}
if (!fs.existsSync(path.resolve(options.cwd, "components.json"))) {
if (await isMonorepoRoot(options.cwd)) {
const targets = await getMonorepoTargets(options.cwd)
const applyTargets: typeof targets = []
for (const target of targets) {
const projectInfo = await getProjectInfo(
path.resolve(options.cwd, target.name)
)
if (projectInfo?.framework && projectInfo.framework.name !== "manual") {
applyTargets.push(target)
}
}
if (applyTargets.length > 0) {
formatMonorepoMessage("apply --preset <preset>", applyTargets, {
cwdFlag: "-c",
})
process.exit(1)
}
}
errors[ERRORS.MISSING_CONFIG] = true
return {
errors,
config: null,
}
}
try {
const config = await getConfig(options.cwd)
return {
errors,
config: config!,
}
} catch {
logger.break()
logger.error(
`An invalid ${highlighter.info(
"components.json"
)} file was found at ${highlighter.info(
options.cwd
)}.\nBefore you can apply a preset, you must create a valid ${highlighter.info(
"components.json"
)} file by running the ${highlighter.info("init")} command.`
)
logger.error(
`Learn more at ${highlighter.info(`${SHADCN_URL}/docs/components-json`)}.`
)
logger.break()
process.exit(1)
}
}

View File

@@ -0,0 +1,46 @@
import path from "path"
import { buildOptionsSchema } from "@/src/commands/build"
import * as ERRORS from "@/src/utils/errors"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import fs from "fs-extra"
import { z } from "zod"
export async function preFlightBuild(
options: z.infer<typeof buildOptionsSchema>
) {
const errors: Record<string, boolean> = {}
const resolvePaths = {
cwd: options.cwd,
registryFile: path.resolve(options.cwd, options.registryFile),
outputDir: path.resolve(options.cwd, options.outputDir),
}
// Ensure registry file exists.
if (!fs.existsSync(resolvePaths.registryFile)) {
errors[ERRORS.BUILD_MISSING_REGISTRY_FILE] = true
}
// Create output directory if it doesn't exist.
await fs.mkdir(resolvePaths.outputDir, { recursive: true })
if (Object.keys(errors).length > 0) {
if (errors[ERRORS.BUILD_MISSING_REGISTRY_FILE]) {
logger.break()
logger.error(
`The path ${highlighter.info(
resolvePaths.registryFile
)} does not exist.`
)
}
logger.break()
process.exit(1)
}
return {
errors,
resolvePaths,
}
}

View File

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

View File

@@ -0,0 +1,194 @@
import path from "path"
import { initOptionsSchema } from "@/src/commands/init"
import { SHADCN_URL } from "@/src/registry/constants"
import * as ERRORS from "@/src/utils/errors"
import {
formatMonorepoMessage,
getMonorepoTargets,
isMonorepoRoot,
} from "@/src/utils/get-monorepo-info"
import { getProjectInfo } from "@/src/utils/get-project-info"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import fs from "fs-extra"
import { z } from "zod"
export async function preFlightInit(
options: z.infer<typeof initOptionsSchema>
) {
const errors: Record<string, boolean> = {}
// Ensure target directory exists.
// Check for empty project. We assume if no package.json exists, the project is empty.
if (
!fs.existsSync(options.cwd) ||
!fs.existsSync(path.resolve(options.cwd, "package.json"))
) {
errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT] = true
return {
errors,
projectInfo: null,
}
}
const projectSpinner = spinner(`Preflight checks.`, {
silent: options.silent,
}).start()
if (
fs.existsSync(path.resolve(options.cwd, "components.json")) &&
!options.force
) {
projectSpinner?.fail()
logger.break()
logger.error(
`A ${highlighter.info(
"components.json"
)} file already exists at ${highlighter.info(
options.cwd
)}.\nTo start over, remove the ${highlighter.info(
"components.json"
)} file and run ${highlighter.info("init")} again.`
)
logger.break()
process.exit(1)
}
projectSpinner?.succeed()
const frameworkSpinner = spinner(`Verifying framework.`, {
silent: options.silent,
}).start()
const tailwind = options.existingConfig?.tailwind as
| Record<string, unknown>
| undefined
const projectInfo = await getProjectInfo(options.cwd, {
configCssFile: typeof tailwind?.css === "string" ? tailwind.css : undefined,
})
if (!projectInfo || projectInfo?.framework.name === "manual") {
errors[ERRORS.UNSUPPORTED_FRAMEWORK] = true
frameworkSpinner?.fail()
// Check if we're in a monorepo root.
// Skip when --monorepo is set.
if (!options.monorepo && (await isMonorepoRoot(options.cwd))) {
const targets = await getMonorepoTargets(options.cwd)
if (targets.length > 0) {
formatMonorepoMessage("init", targets)
process.exit(1)
}
}
logger.break()
if (projectInfo?.framework.links.installation) {
logger.error(
`We could not detect a supported framework at ${highlighter.info(
options.cwd
)}.\n` +
`Visit ${highlighter.info(
projectInfo?.framework.links.installation
)} to manually configure your project.\nOnce configured, you can use the cli to add components.`
)
}
logger.break()
process.exit(1)
}
frameworkSpinner?.succeed(
`Verifying framework. Found ${highlighter.info(
projectInfo.framework.label
)}.`
)
let tailwindSpinnerMessage = "Validating Tailwind CSS."
if (projectInfo.tailwindVersion === "v4") {
tailwindSpinnerMessage = `Validating Tailwind CSS. Found ${highlighter.info(
"v4"
)}.`
}
const tailwindSpinner = spinner(tailwindSpinnerMessage, {
silent: options.silent,
}).start()
if (
projectInfo.tailwindVersion === "v3" &&
(!projectInfo?.tailwindConfigFile || !projectInfo?.tailwindCssFile)
) {
errors[ERRORS.TAILWIND_NOT_CONFIGURED] = true
tailwindSpinner?.fail()
} else if (
projectInfo.tailwindVersion === "v4" &&
!projectInfo?.tailwindCssFile
) {
errors[ERRORS.TAILWIND_NOT_CONFIGURED] = true
tailwindSpinner?.fail()
} else if (!projectInfo.tailwindVersion) {
errors[ERRORS.TAILWIND_NOT_CONFIGURED] = true
tailwindSpinner?.fail()
} else {
tailwindSpinner?.succeed()
}
const tsConfigSpinner = spinner(`Validating import alias.`, {
silent: options.silent,
}).start()
if (!projectInfo?.aliasPrefix) {
errors[ERRORS.IMPORT_ALIAS_MISSING] = true
tsConfigSpinner?.fail()
} else {
tsConfigSpinner?.succeed()
}
if (Object.keys(errors).length > 0) {
if (errors[ERRORS.TAILWIND_NOT_CONFIGURED]) {
logger.break()
logger.error(
`No Tailwind CSS configuration found at ${highlighter.info(
options.cwd
)}.`
)
logger.error(
`It is likely you do not have Tailwind CSS installed or have an invalid configuration.`
)
logger.error(`Install Tailwind CSS then try again.`)
if (projectInfo?.framework.links.tailwind) {
logger.error(
`Visit ${highlighter.info(
projectInfo?.framework.links.tailwind
)} to get started.`
)
}
}
if (errors[ERRORS.IMPORT_ALIAS_MISSING]) {
logger.break()
logger.error(
`Could not find valid path aliases or package imports for ${highlighter.info(
"init"
)}.`
)
logger.error(
`Configure path aliases in ${highlighter.info(
"tsconfig.json"
)} or imports in ${highlighter.info("package.json")}, then run ${highlighter.info(
"init"
)} again.`
)
logger.error(
`Learn more at ${highlighter.info(
`${SHADCN_URL}/docs/installation/manual#configure-import-aliases`
)}.`
)
}
logger.break()
process.exit(1)
}
return {
errors,
projectInfo,
}
}

View File

@@ -0,0 +1,48 @@
import { promises as fs } from "fs"
import { tmpdir } from "os"
import path from "path"
import * as ERRORS from "@/src/utils/errors"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { preFlightMigrate } from "./preflight-migrate"
describe("preFlightMigrate", () => {
let cwd: string
beforeEach(async () => {
cwd = await fs.mkdtemp(path.join(tmpdir(), "shadcn-preflight-migrate-"))
await fs.writeFile(
path.join(cwd, "package.json"),
JSON.stringify({ name: "fixture" })
)
})
afterEach(async () => {
await fs.rm(cwd, { recursive: true, force: true })
})
const getOptions = (cwd: string) => ({
cwd,
list: false,
yes: true,
migration: "rtl",
})
it("requires components.json for config migrations", async () => {
const result = await preFlightMigrate(getOptions(cwd))
expect(result.errors).toEqual({ [ERRORS.MISSING_CONFIG]: true })
expect(result.config).toBeNull()
})
it("requires package.json", async () => {
await fs.rm(path.join(cwd, "package.json"))
const result = await preFlightMigrate(getOptions(cwd))
expect(result.errors).toEqual({
[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]: true,
})
expect(result.config).toBeNull()
})
})

View File

@@ -0,0 +1,63 @@
import path from "path"
import { addOptionsSchema } from "@/src/commands/add"
import { migrateOptionsSchema } from "@/src/commands/migrate"
import { SHADCN_URL } from "@/src/registry/constants"
import * as ERRORS from "@/src/utils/errors"
import { getConfig } from "@/src/utils/get-config"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import fs from "fs-extra"
import { z } from "zod"
export async function preFlightMigrate(
options: z.infer<typeof migrateOptionsSchema>
) {
const errors: Record<string, boolean> = {}
// Ensure target directory exists.
// Check for empty project. We assume if no package.json exists, the project is empty.
if (
!fs.existsSync(options.cwd) ||
!fs.existsSync(path.resolve(options.cwd, "package.json"))
) {
errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT] = true
return {
errors,
config: null,
}
}
// Check for existing components.json file.
if (!fs.existsSync(path.resolve(options.cwd, "components.json"))) {
errors[ERRORS.MISSING_CONFIG] = true
return {
errors,
config: null,
}
}
try {
const config = await getConfig(options.cwd)
return {
errors,
config: config!,
}
} catch (error) {
logger.break()
logger.error(
`An invalid ${highlighter.info(
"components.json"
)} file was found at ${highlighter.info(
options.cwd
)}.\nBefore you can run a migration, you must create a valid ${highlighter.info(
"components.json"
)} file by running the ${highlighter.info("init")} command.`
)
logger.error(
`Learn more at ${highlighter.info(`${SHADCN_URL}/docs/components-json`)}.`
)
logger.break()
process.exit(1)
}
}

View File

@@ -0,0 +1,66 @@
import path from "path"
import { buildOptionsSchema } from "@/src/commands/build"
import * as ERRORS from "@/src/utils/errors"
import { getConfig } from "@/src/utils/get-config"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import fs from "fs-extra"
import { z } from "zod"
export async function preFlightRegistryBuild(
options: z.infer<typeof buildOptionsSchema>
) {
const errors: Record<string, boolean> = {}
const resolvePaths = {
cwd: options.cwd,
registryFile: path.resolve(options.cwd, options.registryFile),
outputDir: path.resolve(options.cwd, options.outputDir),
}
// Ensure registry file exists.
if (!fs.existsSync(resolvePaths.registryFile)) {
errors[ERRORS.BUILD_MISSING_REGISTRY_FILE] = true
return {
errors,
resolvePaths: null,
config: null,
}
}
// Check for existing components.json file.
if (!fs.existsSync(path.resolve(options.cwd, "components.json"))) {
errors[ERRORS.MISSING_CONFIG] = true
return {
errors,
resolvePaths: null,
config: null,
}
}
// Create output directory if it doesn't exist.
await fs.mkdir(resolvePaths.outputDir, { recursive: true })
try {
const config = await getConfig(options.cwd)
return {
errors,
config: config!,
resolvePaths,
}
} catch (error) {
logger.break()
logger.error(
`An invalid ${highlighter.info(
"components.json"
)} file was found at ${highlighter.info(
options.cwd
)}.\nBefore you can build the registry, you must create a valid ${highlighter.info(
"components.json"
)} file by running the ${highlighter.info("init")} command.`
)
logger.break()
process.exit(1)
}
}

View File

@@ -0,0 +1,131 @@
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,
},
sera: {
title: "Sera",
description: "Lucide / Noto Sans + Playfair Display",
style: "sera",
baseColor: "taupe",
theme: "taupe",
chartColor: "taupe",
iconLibrary: "lucide",
font: "noto-sans",
fontHeading: "playfair-display",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
rtl: false,
},
rhea: {
title: "Rhea",
description: "Lucide / Inter",
style: "rhea",
baseColor: "neutral",
theme: "neutral",
chartColor: "neutral",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
menuAccent: "subtle",
menuColor: "default",
radius: "default",
rtl: false,
},
} satisfies Record<
PresetConfig["style"],
PresetConfig & {
description: string
rtl: boolean
title: string
}
>

View File

@@ -0,0 +1,28 @@
// Browser-safe re-export of preset utilities.
// Use `shadcn/preset` for client-side code.
export {
PRESET_BASES,
PRESET_STYLES,
PRESET_BASE_COLORS,
PRESET_THEMES,
PRESET_ICON_LIBRARIES,
PRESET_FONTS,
PRESET_FONT_HEADINGS,
PRESET_RADII,
PRESET_MENU_ACCENTS,
PRESET_MENU_COLORS,
PRESET_CHART_COLORS,
V1_CHART_COLOR_MAP,
DEFAULT_PRESET_CONFIG,
isPresetBase,
toBase62,
fromBase62,
encodePreset,
decodePreset,
isPresetCode,
isValidPreset,
generateRandomConfig,
generateRandomPreset,
type PresetBase,
type PresetConfig,
} from "./preset"

View File

@@ -0,0 +1,305 @@
import { describe, expect, it } from "vitest"
import {
decodePreset,
DEFAULT_PRESET_CONFIG,
encodePreset,
fromBase62,
generateRandomPreset,
isPresetBase,
isPresetCode,
isValidPreset,
parsePresetStyle,
PRESET_BASE_COLORS,
PRESET_BASES,
PRESET_CHART_COLORS,
PRESET_FONT_HEADINGS,
PRESET_FONTS,
PRESET_ICON_LIBRARIES,
PRESET_MENU_ACCENTS,
PRESET_MENU_COLORS,
PRESET_RADII,
PRESET_STYLES,
PRESET_THEMES,
toBase62,
type PresetConfig,
} from "./preset"
describe("preset bases", () => {
it("should include aria without changing preset code versions", () => {
expect(PRESET_BASES).toEqual(["radix", "base", "aria"])
expect(isPresetBase("aria")).toBe(true)
expect(encodePreset({})[0]).toBe("b")
})
it("parses prefixed and legacy preset styles", () => {
expect(parsePresetStyle("aria-nova")).toEqual({
base: "aria",
style: "nova",
})
expect(parsePresetStyle("base-vega")).toEqual({
base: "base",
style: "vega",
})
expect(parsePresetStyle("new-york")).toEqual({
base: undefined,
style: "new-york",
})
expect(parsePresetStyle(undefined)).toEqual({
base: undefined,
style: undefined,
})
})
})
describe("base62", () => {
it("should round-trip numbers", () => {
for (const n of [0, 1, 61, 62, 100, 1000, 8388607]) {
expect(fromBase62(toBase62(n))).toBe(n)
}
})
it("should encode 0 as '0'", () => {
expect(toBase62(0)).toBe("0")
})
it("should return -1 for invalid base62 characters", () => {
expect(fromBase62("!@#")).toBe(-1)
})
})
describe("encodePreset / decodePreset", () => {
it("should round-trip the default config", () => {
const code = encodePreset(DEFAULT_PRESET_CONFIG)
const decoded = decodePreset(code)
expect(decoded).toEqual(DEFAULT_PRESET_CONFIG)
})
it("should round-trip a custom config", () => {
const config: PresetConfig = {
style: "lyra",
baseColor: "zinc",
theme: "blue",
chartColor: "emerald",
iconLibrary: "tabler",
font: "jetbrains-mono",
fontHeading: "playfair-display",
radius: "large",
menuAccent: "bold",
menuColor: "inverted",
}
const code = encodePreset(config)
const decoded = decodePreset(code)
expect(decoded).toEqual(config)
})
it("should produce short codes (max 10 chars)", () => {
const code = encodePreset(DEFAULT_PRESET_CONFIG)
expect(code.length).toBeLessThanOrEqual(10)
})
it("should start with the version character", () => {
const code = encodePreset(DEFAULT_PRESET_CONFIG)
expect(code[0]).toBe("b")
})
it("should handle partial config by filling defaults", () => {
const code = encodePreset({ style: "lyra" })
const decoded = decodePreset(code)
expect(decoded).not.toBeNull()
expect(decoded!.style).toBe("lyra")
expect(decoded!.theme).toBe(DEFAULT_PRESET_CONFIG.theme)
})
it("should round-trip all styles", () => {
for (const style of PRESET_STYLES) {
const code = encodePreset({ style })
const decoded = decodePreset(code)
expect(decoded).not.toBeNull()
expect(decoded!.style).toBe(style)
}
})
it("should round-trip all themes", () => {
for (const theme of PRESET_THEMES) {
const code = encodePreset({ theme })
const decoded = decodePreset(code)
expect(decoded!.theme).toBe(theme)
}
})
it("should round-trip all fonts", () => {
for (const font of PRESET_FONTS) {
const code = encodePreset({ font })
const decoded = decodePreset(code)
expect(decoded!.font).toBe(font)
}
})
it("should round-trip all font headings", () => {
for (const fontHeading of PRESET_FONT_HEADINGS) {
const code = encodePreset({ fontHeading })
const decoded = decodePreset(code)
expect(decoded!.fontHeading).toBe(fontHeading)
}
})
it("should round-trip all icon libraries", () => {
for (const iconLibrary of PRESET_ICON_LIBRARIES) {
const code = encodePreset({ iconLibrary })
const decoded = decodePreset(code)
expect(decoded!.iconLibrary).toBe(iconLibrary)
}
})
it("should round-trip all radii", () => {
for (const radius of PRESET_RADII) {
const code = encodePreset({ radius })
const decoded = decodePreset(code)
expect(decoded!.radius).toBe(radius)
}
})
it("should round-trip all base colors", () => {
for (const baseColor of PRESET_BASE_COLORS) {
const code = encodePreset({ baseColor })
const decoded = decodePreset(code)
expect(decoded!.baseColor).toBe(baseColor)
}
})
it("should round-trip all chart colors", () => {
for (const chartColor of PRESET_CHART_COLORS) {
const code = encodePreset({ chartColor })
const decoded = decodePreset(code)
expect(decoded!.chartColor).toBe(chartColor)
}
})
it("should round-trip all menu accents and colors", () => {
for (const menuAccent of PRESET_MENU_ACCENTS) {
for (const menuColor of PRESET_MENU_COLORS) {
const code = encodePreset({ menuAccent, menuColor })
const decoded = decodePreset(code)
expect(decoded!.menuAccent).toBe(menuAccent)
expect(decoded!.menuColor).toBe(menuColor)
}
}
})
it("should round-trip default-translucent menu color", () => {
const code = encodePreset({ menuColor: "default-translucent" })
const decoded = decodePreset(code)
expect(decoded!.menuColor).toBe("default-translucent")
})
})
describe("decodePreset edge cases", () => {
it("should return null for empty string", () => {
expect(decodePreset("")).toBeNull()
})
it("should return null for single character", () => {
expect(decodePreset("A")).toBeNull()
})
it("should return null for wrong version prefix", () => {
expect(decodePreset("c123")).toBeNull()
})
it("should return null for invalid base62 characters", () => {
expect(decodePreset("A!@#")).toBeNull()
})
})
describe("v1/v2 backward compatibility", () => {
it("should decode old 'a'-prefixed codes without chartColor", () => {
const decoded = decodePreset("a0")
expect(decoded).not.toBeNull()
expect(decoded!.style).toBe("nova")
expect(decoded!.theme).toBe("neutral")
expect(decoded!.chartColor).toBeUndefined()
expect(decoded!.fontHeading).toBe("inherit")
})
it("should decode new 'b'-prefixed codes with chartColor", () => {
const code = encodePreset({ theme: "blue", chartColor: "emerald" })
expect(code[0]).toBe("b")
const decoded = decodePreset(code)
expect(decoded).not.toBeNull()
expect(decoded!.theme).toBe("blue")
expect(decoded!.chartColor).toBe("emerald")
})
it("should decode old b-prefixed codes with fontHeading defaulting to inherit", () => {
const decoded = decodePreset("b0")
expect(decoded).not.toBeNull()
expect(decoded!.fontHeading).toBe("inherit")
})
it("should encode always produces 'b'-prefixed codes", () => {
const code = encodePreset({})
expect(code[0]).toBe("b")
})
it("should accept both 'a' and 'b' in isPresetCode", () => {
expect(isPresetCode("a0")).toBe(true)
expect(isPresetCode("b0")).toBe(true)
expect(isPresetCode("c0")).toBe(false)
})
})
describe("isPresetCode", () => {
it("should return true for valid preset codes", () => {
const code = encodePreset(DEFAULT_PRESET_CONFIG)
expect(isPresetCode(code)).toBe(true)
})
it("should return false for empty string", () => {
expect(isPresetCode("")).toBe(false)
})
it("should return false for URLs", () => {
expect(isPresetCode("https://ui.shadcn.com/init?foo=bar")).toBe(false)
})
it("should return false for named presets", () => {
expect(isPresetCode("radix-nova")).toBe(false)
})
it("should return false for strings that are too long", () => {
expect(isPresetCode("A1234567890")).toBe(false)
})
it("should return false for invalid characters after version", () => {
expect(isPresetCode("A!@#")).toBe(false)
})
})
describe("isValidPreset", () => {
it("should return true for encodable presets", () => {
const code = encodePreset(DEFAULT_PRESET_CONFIG)
expect(isValidPreset(code)).toBe(true)
})
it("should return false for invalid codes", () => {
expect(isValidPreset("")).toBe(false)
expect(isValidPreset("c123")).toBe(false)
})
})
describe("generateRandomPreset", () => {
it("should produce a valid preset code", () => {
const code = generateRandomPreset()
expect(isPresetCode(code)).toBe(true)
expect(isValidPreset(code)).toBe(true)
})
it("should round-trip through decode", () => {
const code = generateRandomPreset()
const decoded = decodePreset(code)
expect(decoded).not.toBeNull()
const reEncoded = encodePreset(decoded!)
expect(reEncoded).toBe(code)
})
})

View File

@@ -0,0 +1,305 @@
// Preset encoding/decoding utilities.
// Bit-packs design system params into a single integer,
// then encodes as base62 with a version prefix character.
// Browser-safe: no Node.js dependencies.
//
// Rules for backward compat:
// 1. Never reorder existing value arrays — only append.
// 2. New fields must have their default at index 0.
// 3. Only append new fields to the end of PRESET_FIELDS.
// 4. Stay under 53 bits total (JS safe integer limit).
// Value arrays — order matters for backward compat. Never reorder, only append.
export const PRESET_BASES = ["radix", "base", "aria"] as const
export type PresetBase = (typeof PRESET_BASES)[number]
export function isPresetBase(value: unknown): value is PresetBase {
return PRESET_BASES.includes(value as PresetBase)
}
export function parsePresetStyle(style: string | undefined) {
const base = style
? PRESET_BASES.find((base) => style.startsWith(`${base}-`))
: undefined
return {
base,
style: base ? style?.slice(base.length + 1) : style,
}
}
export const PRESET_STYLES = [
"nova",
"vega",
"maia",
"lyra",
"mira",
"luma",
"sera",
"rhea",
] as const
export const PRESET_BASE_COLORS = [
"neutral",
"stone",
"zinc",
"gray",
"mauve",
"olive",
"mist",
"taupe",
] as const
export const PRESET_THEMES = [
"neutral",
"stone",
"zinc",
"gray",
"amber",
"blue",
"cyan",
"emerald",
"fuchsia",
"green",
"indigo",
"lime",
"orange",
"pink",
"purple",
"red",
"rose",
"sky",
"teal",
"violet",
"yellow",
"mauve",
"olive",
"mist",
"taupe",
] as const
export const PRESET_CHART_COLORS = PRESET_THEMES
// Before v2, base-color themes had colored chart palettes
// borrowed from these colored themes. Used by consumers to
// restore the original chart colors when decoding v1 presets.
export const V1_CHART_COLOR_MAP: Record<string, string> = {
neutral: "blue",
stone: "lime",
zinc: "amber",
mauve: "emerald",
olive: "violet",
mist: "rose",
taupe: "cyan",
}
export const PRESET_ICON_LIBRARIES = [
"lucide",
"hugeicons",
"tabler",
"phosphor",
"remixicon",
] as const
export const PRESET_FONTS = [
"inter",
"noto-sans",
"nunito-sans",
"figtree",
"roboto",
"raleway",
"dm-sans",
"public-sans",
"outfit",
"jetbrains-mono",
"geist",
"geist-mono",
"lora",
"merriweather",
"playfair-display",
"noto-serif",
"roboto-slab",
"oxanium",
"manrope",
"space-grotesk",
"montserrat",
"ibm-plex-sans",
"source-sans-3",
"instrument-sans",
"eb-garamond",
"instrument-serif",
] as const
export const PRESET_FONT_HEADINGS = ["inherit", ...PRESET_FONTS] as const
export const PRESET_RADII = [
"default",
"none",
"small",
"medium",
"large",
] as const
export const PRESET_MENU_ACCENTS = ["subtle", "bold"] as const
export const PRESET_MENU_COLORS = [
"default",
"inverted",
"default-translucent",
"inverted-translucent",
] as const
// V1 fields (version "a"): 40 bits. No chartColor.
const PRESET_FIELDS_V1 = [
{ key: "menuColor", values: PRESET_MENU_COLORS, bits: 3 },
{ key: "menuAccent", values: PRESET_MENU_ACCENTS, bits: 3 },
{ key: "radius", values: PRESET_RADII, bits: 4 },
{ key: "font", values: PRESET_FONTS, bits: 6 },
{ key: "iconLibrary", values: PRESET_ICON_LIBRARIES, bits: 6 },
{ key: "theme", values: PRESET_THEMES, bits: 6 },
{ key: "baseColor", values: PRESET_BASE_COLORS, bits: 6 },
{ key: "style", values: PRESET_STYLES, bits: 6 },
] as const
// V2 fields (version "b"): 51 bits. Adds chartColor and fontHeading.
const PRESET_FIELDS_V2 = [
...PRESET_FIELDS_V1,
{ key: "chartColor", values: PRESET_CHART_COLORS, bits: 6 },
{ key: "fontHeading", values: PRESET_FONT_HEADINGS, bits: 5 },
] as const
export type PresetConfig = {
style: (typeof PRESET_STYLES)[number]
baseColor: (typeof PRESET_BASE_COLORS)[number]
theme: (typeof PRESET_THEMES)[number]
chartColor?: (typeof PRESET_CHART_COLORS)[number]
iconLibrary: (typeof PRESET_ICON_LIBRARIES)[number]
font: (typeof PRESET_FONTS)[number]
fontHeading: (typeof PRESET_FONT_HEADINGS)[number]
radius: (typeof PRESET_RADII)[number]
menuAccent: (typeof PRESET_MENU_ACCENTS)[number]
menuColor: (typeof PRESET_MENU_COLORS)[number]
}
export const DEFAULT_PRESET_CONFIG: PresetConfig = Object.fromEntries(
PRESET_FIELDS_V2.map((f) => [f.key, f.values[0]])
) as PresetConfig
// Base62 alphabet.
const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
// Version prefixes — "a" = v1 (no chartColor), "b" = v2 (with chartColor).
const CURRENT_VERSION = "b"
const VALID_VERSIONS = ["a", "b"] as const
export function toBase62(num: number) {
if (num === 0) return "0"
let result = ""
let n = num
while (n > 0) {
result = BASE62[n % 62] + result
n = Math.floor(n / 62)
}
return result
}
export function fromBase62(str: string) {
let result = 0
for (let i = 0; i < str.length; i++) {
const idx = BASE62.indexOf(str[i])
if (idx === -1) return -1
result = result * 62 + idx
}
return result
}
// Encode a PresetConfig into a short alphanumeric code.
// Always produces v2 ("b") codes.
export function encodePreset(config: Partial<PresetConfig>) {
const merged = { ...DEFAULT_PRESET_CONFIG, ...config }
// Uses multiplication instead of bitwise ops (JS bitwise truncates to 32 bits).
let bits = 0
let offset = 0
for (const field of PRESET_FIELDS_V2) {
const idx = (field.values as readonly string[]).indexOf(
merged[field.key as keyof PresetConfig] as string
)
bits += (idx === -1 ? 0 : idx) * 2 ** offset
offset += field.bits
}
return CURRENT_VERSION + toBase62(bits)
}
// Decode a preset code back into a PresetConfig.
// "a"-prefixed codes use v1 fields (no chartColor).
// "b"-prefixed codes use v2 fields (with chartColor).
export function decodePreset(code: string): PresetConfig | null {
if (!code || code.length < 2) {
return null
}
const version = code[0]
if (!VALID_VERSIONS.includes(version as (typeof VALID_VERSIONS)[number])) {
return null
}
const fields = version === "a" ? PRESET_FIELDS_V1 : PRESET_FIELDS_V2
const bits = fromBase62(code.slice(1))
if (bits < 0) return null
const result = {} as Record<string, string>
let offset = 0
for (const field of fields) {
const idx = Math.floor(bits / 2 ** offset) % 2 ** field.bits
result[field.key] =
idx < field.values.length ? field.values[idx] : field.values[0]
offset += field.bits
}
if (version === "a") {
result.fontHeading = "inherit"
}
return result as PresetConfig
}
// Check if a string looks like a preset code (version char + base62).
export function isPresetCode(value: string) {
if (!value || value.length < 2 || value.length > 10) {
return false
}
if (!VALID_VERSIONS.includes(value[0] as (typeof VALID_VERSIONS)[number])) {
return false
}
for (let i = 1; i < value.length; i++) {
if (BASE62.indexOf(value[i]) === -1) {
return false
}
}
return true
}
// Validate that a preset code decodes successfully.
export function isValidPreset(code: string) {
return decodePreset(code) !== null
}
// Generate a random PresetConfig.
export function generateRandomConfig(): PresetConfig {
const pick = <T>(arr: readonly T[]) =>
arr[Math.floor(Math.random() * arr.length)]
return Object.fromEntries(
PRESET_FIELDS_V2.map((f) => [f.key, pick(f.values)])
) as PresetConfig
}
// Generate a random preset code.
export function generateRandomPreset() {
return encodePreset(generateRandomConfig())
}

View File

@@ -0,0 +1,181 @@
import { REGISTRY_URL } from "@/src/registry/constants"
import prompts from "prompts"
import { beforeEach, describe, expect, it, vi } from "vitest"
import {
DEFAULT_PRESETS,
promptForBase,
resolveCreateUrl,
resolveInitUrl,
} from "./presets"
vi.mock("prompts", () => ({
default: vi.fn(),
}))
const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "")
const mockPreset = {
name: "default",
title: "Default",
description: "The default preset.",
base: "radix" as const,
style: "new-york-v4",
baseColor: "neutral",
theme: "default",
iconLibrary: "lucide",
font: "inter",
fontHeading: "inherit",
rtl: false,
menuAccent: "subtle" as const,
menuColor: "default" as const,
radius: "0.5",
}
describe("promptForBase", () => {
beforeEach(() => {
vi.mocked(prompts).mockReset()
})
it("should offer and return the aria base", async () => {
vi.mocked(prompts).mockResolvedValue({ base: "aria" })
await expect(promptForBase()).resolves.toBe("aria")
expect(prompts).toHaveBeenCalledWith(
expect.objectContaining({
choices: [
{ title: "Base UI (Recommended)", value: "base" },
{ title: "React Aria", value: "aria" },
{ title: "Radix UI", value: "radix" },
],
})
)
})
})
describe("createPresetUrl", () => {
it("should not include rtl by default", () => {
const url = resolveCreateUrl()
const parsed = new URL(url)
expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/create`)
expect(parsed.searchParams.has("rtl")).toBe(false)
})
it("should append search params when provided", () => {
const url = resolveCreateUrl({
rtl: true,
pointer: true,
template: "next",
})
const parsed = new URL(url)
expect(parsed.searchParams.get("rtl")).toBe("true")
expect(parsed.searchParams.get("pointer")).toBe("true")
expect(parsed.searchParams.get("template")).toBe("next")
})
})
describe("buildInitUrl", () => {
it("should build url with all preset fields as query params", () => {
const url = resolveInitUrl(mockPreset)
const parsed = new URL(url)
expect(parsed.origin + parsed.pathname).toBe(`${SHADCN_URL}/init`)
expect(parsed.searchParams.get("base")).toBe("radix")
expect(parsed.searchParams.get("style")).toBe("new-york-v4")
expect(parsed.searchParams.get("baseColor")).toBe("neutral")
expect(parsed.searchParams.get("theme")).toBe("default")
expect(parsed.searchParams.get("iconLibrary")).toBe("lucide")
expect(parsed.searchParams.get("font")).toBe("inter")
expect(parsed.searchParams.get("rtl")).toBe("false")
expect(parsed.searchParams.get("menuAccent")).toBe("subtle")
expect(parsed.searchParams.get("menuColor")).toBe("default")
expect(parsed.searchParams.get("radius")).toBe("0.5")
})
it("should set rtl=true when preset.rtl is true", () => {
const url = resolveInitUrl({ ...mockPreset, rtl: true })
const parsed = new URL(url)
expect(parsed.searchParams.get("rtl")).toBe("true")
})
it("should include template when provided", () => {
const url = resolveInitUrl(mockPreset, { template: "next" })
const parsed = new URL(url)
expect(parsed.searchParams.get("template")).toBe("next")
})
it("should not include template when not provided", () => {
const url = resolveInitUrl(mockPreset)
const parsed = new URL(url)
expect(parsed.searchParams.has("template")).toBe(false)
})
it("should include chartColor when provided", () => {
const url = resolveInitUrl({ ...mockPreset, chartColor: "emerald" })
const parsed = new URL(url)
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 include chartColor from default presets", () => {
const url = resolveInitUrl({ ...DEFAULT_PRESETS.sera, base: "base" })
const parsed = new URL(url)
expect(parsed.searchParams.get("chartColor")).toBe("taupe")
})
it("should not include chartColor when not provided", () => {
const url = resolveInitUrl(mockPreset)
const parsed = new URL(url)
expect(parsed.searchParams.has("chartColor")).toBe(false)
})
it("should include fontHeading when it is explicitly set", () => {
const url = resolveInitUrl({
...mockPreset,
fontHeading: "playfair-display",
})
const parsed = new URL(url)
expect(parsed.searchParams.get("fontHeading")).toBe("playfair-display")
})
it("should not include fontHeading when it inherits the body font", () => {
const url = resolveInitUrl(mockPreset)
const parsed = new URL(url)
expect(parsed.searchParams.has("fontHeading")).toBe(false)
})
it("should include preset code when provided", () => {
const url = resolveInitUrl(mockPreset, { preset: "a0" })
const parsed = new URL(url)
expect(parsed.searchParams.get("preset")).toBe("a0")
})
it("should not include preset when not provided", () => {
const url = resolveInitUrl(mockPreset)
const parsed = new URL(url)
expect(parsed.searchParams.has("preset")).toBe(false)
})
it("should include pointer when enabled", () => {
const url = resolveInitUrl(mockPreset, { pointer: true })
const parsed = new URL(url)
expect(parsed.searchParams.get("pointer")).toBe("true")
})
it("should not include pointer when disabled", () => {
const url = resolveInitUrl(mockPreset, { pointer: false })
const parsed = new URL(url)
expect(parsed.searchParams.has("pointer")).toBe(false)
})
it("should include pointer with preset codes", () => {
const url = resolveInitUrl(mockPreset, { preset: "a0", pointer: true })
const parsed = new URL(url)
expect(parsed.searchParams.get("preset")).toBe("a0")
expect(parsed.searchParams.get("pointer")).toBe("true")
})
})

View File

@@ -0,0 +1,285 @@
import { getRegistryItems } from "@/src/registry/api"
import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder"
import { configWithDefaults } from "@/src/registry/config"
import { REGISTRY_URL, SHADCN_URL } from "@/src/registry/constants"
import { type registryConfigSchema } from "@/src/registry/schema"
import { isUrl } from "@/src/registry/utils"
import { createConfig } from "@/src/utils/get-config"
import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger"
import { ensureRegistriesInConfig } from "@/src/utils/registries"
import open from "open"
import prompts from "prompts"
import { type z } from "zod"
import { DEFAULT_PRESETS } from "./defaults"
import { type PresetBase } from "./preset"
export { DEFAULT_PRESETS } from "./defaults"
export function resolveCreateUrl(
searchParams?: Partial<{
command: "create" | "init"
template: string
rtl: boolean
pointer: boolean
base: PresetBase
}>
) {
const url = new URL(`${SHADCN_URL}/create`)
const { rtl, pointer, ...params } = searchParams ?? {}
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
url.searchParams.set(key, String(value))
}
}
// Do not set rtl if it's false.
if (rtl) {
url.searchParams.set("rtl", "true")
}
if (pointer) {
url.searchParams.set("pointer", "true")
}
return url.toString()
}
export async function promptToOpenPresetBuilder(options: {
createUrl: string
followUp: string
prompt?: boolean
}) {
logger.break()
logger.log(
` Build your custom preset on ${highlighter.info(options.createUrl)}`
)
logger.log(` ${options.followUp}`)
logger.break()
if (options.prompt === false) {
return
}
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Open in browser?",
initial: true,
})
if (proceed) {
await open(options.createUrl)
}
}
export function resolveInitUrl(
preset: {
base: PresetBase
style: string
baseColor: string
theme: string
chartColor?: string
iconLibrary: string
font: string
fontHeading?: string
rtl: boolean
menuAccent: string
menuColor: string
radius: string
},
options?: {
template?: string
preset?: string
only?: string
pointer?: boolean
}
) {
const params = new URLSearchParams({
base: preset.base,
style: preset.style,
baseColor: preset.baseColor,
theme: preset.theme,
iconLibrary: preset.iconLibrary,
font: preset.font,
rtl: String(preset.rtl ?? false),
menuAccent: preset.menuAccent,
menuColor: preset.menuColor,
radius: preset.radius,
})
if (preset.chartColor && preset.chartColor !== "neutral") {
params.set("chartColor", preset.chartColor)
}
if (preset.fontHeading && preset.fontHeading !== "inherit") {
params.set("fontHeading", preset.fontHeading)
}
// Pass the original preset code so the server can apply
// version-specific backward-compat fixups.
if (options?.preset) {
params.set("preset", options.preset)
}
if (options?.template) {
params.set("template", options.template)
}
if (options?.only) {
params.set("only", options.only)
}
if (options?.pointer) {
params.set("pointer", "true")
}
// Signal the server to record this init run.
params.set("track", "1")
return `${SHADCN_URL}/init?${params.toString()}`
}
export async function promptForBase() {
const { base } = await prompts({
type: "select",
name: "base",
message: `Select a ${highlighter.info("component library")}`,
choices: [
{ title: "Base UI (Recommended)", value: "base" },
{ title: "React Aria", value: "aria" },
{ title: "Radix UI", value: "radix" },
],
})
if (!base) process.exit(1)
return base as PresetBase
}
export async function promptForPreset(options: {
rtl: boolean
base: PresetBase
template?: string
pointer?: boolean
}) {
const presets = Object.entries(DEFAULT_PRESETS)
const { selectedPreset } = await prompts({
type: "select",
name: "selectedPreset",
message: `Which ${highlighter.info("preset")} would you like to use?`,
choices: [
...presets.map(([name, preset]) => ({
title: preset.title,
description: preset.description,
value: name,
})),
{
title: "Custom",
description: `Build your own at ${highlighter.info(`${SHADCN_URL}/create`)}`,
value: "custom",
},
],
})
if (!selectedPreset) {
process.exit(1)
}
if (selectedPreset === "custom") {
const createUrl = resolveCreateUrl({
command: "init",
rtl: options.rtl,
pointer: options.pointer,
base: options.base,
...(options.template && { template: options.template }),
})
await promptToOpenPresetBuilder({
createUrl,
followUp: `Then ${highlighter.info(
"copy and run the command"
)} from ui.shadcn.com.`,
})
process.exit(0)
}
const preset = DEFAULT_PRESETS[selectedPreset as keyof typeof DEFAULT_PRESETS]
if (!preset) {
process.exit(1)
}
return {
url: resolveInitUrl(
{ ...preset, base: options.base, rtl: options.rtl },
{
template: options.template,
pointer: options.pointer,
}
),
base: options.base,
}
}
export async function resolveRegistryBaseConfig(
initUrl: string,
cwd: string,
options?: {
registries?: z.infer<typeof registryConfigSchema>
}
) {
// Use a shadow config to fetch the registry:base item.
let shadowConfig = configWithDefaults(
createConfig({
resolvedPaths: {
cwd,
},
...(options?.registries && { registries: options.registries }),
})
)
// Ensure all registries used in the init URL are configured.
const { config: updatedConfig } = await ensureRegistriesInConfig(
[initUrl],
shadowConfig,
{
silent: true,
writeFile: false,
}
)
shadowConfig = updatedConfig
// This forces a shadowConfig validation early in the process.
buildUrlAndHeadersForRegistryItem(initUrl, shadowConfig)
const [item] = await getRegistryItems([initUrl], {
config: shadowConfig,
useCache: true,
})
const registryBaseConfig =
item?.type === "registry:base" && item.config ? item.config : undefined
// Strip the track param so subsequent fetches don't re-trigger tracking.
let cleanUrl = initUrl
if (isShadcnInitUrl(initUrl)) {
const url = new URL(initUrl)
url.searchParams.delete("track")
cleanUrl = url.toString()
}
return {
registryBaseConfig,
installStyleIndex: item?.extends !== "none",
url: cleanUrl,
}
}
function isShadcnInitUrl(url: string) {
try {
return new URL(url).pathname === "/init" && url.startsWith(SHADCN_URL)
} catch {
return false
}
}

View File

@@ -0,0 +1,349 @@
import { promises as fs } from "fs"
import os from "os"
import path from "path"
import { FRAMEWORKS } from "@/src/utils/frameworks"
import { createConfig } from "@/src/utils/get-config"
import { afterEach, describe, expect, it } from "vitest"
import { encodePreset, type PresetConfig } from "./preset"
import { resolveProjectPreset } from "./resolve"
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!))
expect(result.fallbacks).toEqual([])
})
it("resolves self-referential theme font vars from root vars", async () => {
const config = await createTestConfig({
css: `:root {
--radius: 0.625rem;
--primary: oklch(0.205 0 0);
--chart-1: oklch(0.87 0 0);
--font-sans: "Lora Variable", serif;
}
.dark {
--primary: oklch(0.205 0 0);
--chart-1: oklch(0.87 0 0);
}
@theme inline {
--font-sans: var(--font-sans);
}`,
})
const result = await resolveProjectPreset(config)
expect(result.values).toMatchObject({
font: "lora",
})
expect(result.fallbacks).not.toContain("font")
})
it("matches serif font imports against the serif root variable", async () => {
const config = await createTestConfig({
style: "base-nova",
css: `@import "@fontsource-variable/inter";
@import "@fontsource-variable/eb-garamond";
: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);
}`,
})
const result = await resolveProjectPreset(config)
expect(result.values).toMatchObject({
font: "inter",
})
expect(result.fallbacks).not.toContain("font")
})
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!))
expect(result.fallbacks).toEqual([
"theme",
"chartColor",
"font",
"fontHeading",
"radius",
])
})
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 an empty preset 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,
fallbacks: [],
values: null,
})
})
it("resolves presets from aria styles", async () => {
const config = await createTestConfig({
style: "aria-luma",
css: presetCssWithHeadingFont,
})
const result = await resolveProjectPreset(config)
expect(result.values?.style).toBe("luma")
expect(result.code).toBe(encodePreset(result.values!))
})
})

View File

@@ -0,0 +1,812 @@
import { existsSync, promises as fs } from "fs"
import path from "path"
import { findTailwindColorFamily } from "@/src/colors"
import type { Config } from "@/src/utils/get-config"
import { getProjectInfo, type ProjectInfo } from "@/src/utils/get-project-info"
import postcss from "postcss"
import { Node, Project, ScriptKind, SyntaxKind } from "ts-morph"
import { DEFAULT_PRESETS } from "./defaults"
import {
encodePreset,
parsePresetStyle,
PRESET_BASE_COLORS,
PRESET_FONT_HEADINGS,
PRESET_FONTS,
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"]>([
"eb-garamond",
"instrument-serif",
"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 EMPTY_NEXT_FONT_STATE: NextFontState = {
appliedBodyVariable: null,
variables: {},
}
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, fallbacks: [], values: null }
}
const defaults = DEFAULT_PRESETS[style]
const cssState = await readCssState(config.resolvedPaths.tailwindCss)
const baseColor = asPresetValue<PresetConfig["baseColor"]>(
PRESET_BASE_COLOR_SET,
config.tailwind.baseColor
)
const theme = matchTheme(cssState)
const chartColor = matchChartColor(cssState)
const iconLibrary = asPresetValue<PresetConfig["iconLibrary"]>(
PRESET_ICON_LIBRARY_SET,
config.iconLibrary
)
let resolvedFont: PresetConfig["font"] | null = resolveBodyFont(
cssState,
EMPTY_NEXT_FONT_STATE
)
let font = resolvedFont ?? defaults.font
let resolvedFontHeading: PresetConfig["fontHeading"] | null =
resolveHeadingFont(cssState, font, EMPTY_NEXT_FONT_STATE)
if (!resolvedFont || !resolvedFontHeading) {
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)
resolvedFont ??= resolveBodyFont(cssState, nextFonts)
font = resolvedFont ?? defaults.font
resolvedFontHeading ??= resolveHeadingFont(cssState, font, nextFonts)
}
const fontHeading = normalizeFontHeading(
resolvedFontHeading ?? defaults.fontHeading,
font,
defaults.fontHeading
)
const radius = matchRadius(cssState.rootVars["--radius"])
const menuAccent = asPresetValue<PresetConfig["menuAccent"]>(
PRESET_MENU_ACCENT_SET,
config.menuAccent
)
const menuColor = asPresetValue<PresetConfig["menuColor"]>(
PRESET_MENU_COLOR_SET,
config.menuColor
)
const values = {
style,
baseColor: baseColor ?? defaults.baseColor,
theme: theme ?? defaults.theme,
chartColor: chartColor ?? defaults.chartColor,
iconLibrary: iconLibrary ?? defaults.iconLibrary,
font,
fontHeading,
radius: radius ?? defaults.radius,
menuAccent: menuAccent ?? defaults.menuAccent,
menuColor: menuColor ?? defaults.menuColor,
} satisfies PresetConfig
const fallbacks = [
!baseColor && "baseColor",
!theme && "theme",
!chartColor && "chartColor",
!iconLibrary && "iconLibrary",
!resolvedFont && "font",
!resolvedFontHeading && "fontHeading",
!radius && "radius",
!menuAccent && "menuAccent",
!menuColor && "menuColor",
].filter(Boolean)
return {
code: encodePreset(values),
fallbacks,
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) {
const normalized = parsePresetStyle(style).style
if (!normalized) {
return null
}
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) {
return null
}
if (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) {
const themeValue = state.themeVars[variable]
if (themeValue && getVarReference(themeValue) !== variable) {
return themeValue
}
return state.rootVars[variable] ?? themeValue ?? 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())
}
// Last-resort heuristic for dynamic className expressions.
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 asPresetValue<T extends string>(
set: Set<string>,
value: string | undefined
) {
return value && set.has(value) ? (value as T) : null
}

View File

@@ -0,0 +1,201 @@
import { promises as fs } from "fs"
import { createServer } from "http"
import { tmpdir } from "os"
import path from "path"
import { createConfig } from "@/src/utils/get-config"
import { afterEach, describe, expect, it } from "vitest"
import { addRegistryItems } from "./add"
import { getRegistriesConfig } from "./api"
import { RegistryNotConfiguredError } from "./errors"
describe("addRegistryItems package.json registries", () => {
let tempDir: string | undefined
afterEach(async () => {
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true })
tempDir = undefined
}
})
it("installs a universal item with a registries-only config", async () => {
const registryItem = {
name: "agent",
type: "registry:file",
files: [
{
path: "agent.ts",
type: "registry:file",
target: "generated/agent.ts",
content: "export const agent = true\n",
},
],
}
const server = createServer((request, response) => {
if (request.url === "/agent.json") {
if (request.headers.authorization !== "Bearer project-token") {
response.writeHead(401)
response.end()
return
}
response.writeHead(200, { "content-type": "application/json" })
response.end(JSON.stringify(registryItem))
return
}
response.writeHead(404)
response.end()
})
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve)
})
try {
const address = server.address()
if (!address || typeof address === "string") {
throw new Error("Failed to start test registry server.")
}
tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-add-registry-"))
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({
name: "registry-consumer",
private: true,
registries: {
"@acme": {
url: `http://127.0.0.1:${address.port}/{name}.json`,
headers: {
Authorization: "Bearer ${REGISTRY_TOKEN}",
},
},
},
})
)
await fs.writeFile(
path.join(tempDir, ".env"),
"REGISTRY_TOKEN=project-token\n"
)
const config = await getRegistriesConfig(tempDir)
await addRegistryItems(["@acme/agent"], {
cwd: tempDir,
config,
silent: true,
})
await expect(
fs.readFile(path.join(tempDir, "generated/agent.ts"), "utf8")
).resolves.toBe("export const agent = true\n")
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
})
it("installs a non-universal item with a full project config", async () => {
const registryItem = {
name: "button",
type: "registry:ui",
files: [
{
path: "button.tsx",
type: "registry:ui",
content: "export function Button() {}\n",
},
],
}
const server = createServer((request, response) => {
if (request.url === "/button.json") {
response.writeHead(200, { "content-type": "application/json" })
response.end(JSON.stringify(registryItem))
return
}
response.writeHead(404)
response.end()
})
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve)
})
try {
const address = server.address()
if (!address || typeof address === "string") {
throw new Error("Failed to start test registry server.")
}
tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-add-registry-"))
await fs.writeFile(
path.join(tempDir, "package.json"),
JSON.stringify({
name: "registry-consumer",
private: true,
})
)
const config = createConfig({
registries: {
"@acme": `http://127.0.0.1:${address.port}/{name}.json`,
},
resolvedPaths: {
cwd: tempDir,
components: path.join(tempDir, "components"),
hooks: path.join(tempDir, "hooks"),
lib: path.join(tempDir, "lib"),
ui: path.join(tempDir, "components/ui"),
utils: path.join(tempDir, "lib/utils"),
},
})
await addRegistryItems(["@acme/button"], {
cwd: tempDir,
config,
silent: true,
skipFonts: true,
})
await expect(
fs.readFile(path.join(tempDir, "components/ui/button.tsx"), "utf8")
).resolves.toBe("export function Button() {}\n")
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
})
it("requires custom registry namespaces to be configured", async () => {
tempDir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-add-registry-"))
await expect(
addRegistryItems(["@missing/agent"], {
cwd: tempDir,
config: {
registries: {},
},
silent: true,
})
).rejects.toBeInstanceOf(RegistryNotConfiguredError)
})
})

View File

@@ -0,0 +1,452 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { addRegistryItems } from "./add"
import { ConfigParseError } from "./errors"
const {
mockAddComponents,
mockConfigWithDefaults,
mockLoadEnvFiles,
mockResolveRegistryTree,
mockWithRegistryContext,
} = vi.hoisted(() => ({
mockAddComponents: vi.fn(),
mockConfigWithDefaults: vi.fn(),
mockLoadEnvFiles: vi.fn(),
mockResolveRegistryTree: vi.fn(),
mockWithRegistryContext: vi.fn(),
}))
vi.mock("@/src/registry/config", () => ({
configWithDefaults: mockConfigWithDefaults,
}))
vi.mock("@/src/registry/resolver", () => ({
resolveRegistryTree: mockResolveRegistryTree,
}))
vi.mock("@/src/registry/context", () => ({
withRegistryContext: mockWithRegistryContext,
}))
vi.mock("@/src/utils/add-components", () => ({
addComponents: mockAddComponents,
}))
vi.mock("@/src/utils/env-loader", () => ({
loadEnvFiles: mockLoadEnvFiles,
}))
describe("addRegistryItems", () => {
const projectConfig = {
style: "new-york",
rsc: false,
tsx: true,
tailwind: {
config: "tailwind.config.ts",
css: "app/globals.css",
baseColor: "neutral",
cssVariables: true,
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
},
registries: {
"@acme": "https://acme.com/{name}.json",
},
resolvedPaths: {
cwd: "/project",
tailwindConfig: "/project/tailwind.config.ts",
tailwindCss: "/project/app/globals.css",
utils: "/project/lib/utils",
components: "/project/components",
ui: "/project/components/ui",
lib: "/project/lib",
hooks: "/project/hooks",
},
}
const projectEnv = { REGISTRY_TOKEN: "project-token" }
beforeEach(() => {
vi.clearAllMocks()
mockLoadEnvFiles.mockResolvedValue(projectEnv)
mockWithRegistryContext.mockImplementation((callback) => callback())
mockConfigWithDefaults.mockImplementation((config) => config)
mockResolveRegistryTree.mockResolvedValue({ files: [] })
})
it("uses a full project config to install registry items", async () => {
const resolvedTree = { files: [] }
mockResolveRegistryTree.mockResolvedValueOnce(resolvedTree)
await addRegistryItems(["@acme/button"], {
cwd: "/project",
config: projectConfig,
overwrite: true,
silent: true,
skipFonts: true,
path: "src/components",
})
expect(mockLoadEnvFiles).toHaveBeenCalledWith("/project", {
processEnv: expect.any(Object),
})
expect(mockLoadEnvFiles.mock.calls[0][1].processEnv).not.toBe(process.env)
expect(mockWithRegistryContext).toHaveBeenCalledWith(expect.any(Function), {
env: projectEnv,
})
expect(mockConfigWithDefaults).toHaveBeenCalledWith(projectConfig)
expect(mockResolveRegistryTree).toHaveBeenCalledWith(
["@acme/button"],
projectConfig,
{ requireUniversal: false, useCache: true }
)
expect(mockAddComponents).toHaveBeenCalledWith(
["@acme/button"],
projectConfig,
{
overwrite: true,
silent: true,
skipFonts: true,
path: "src/components",
interactive: false,
overwriteCssVars: undefined,
resolvedTree,
}
)
})
it("uses a registries-only config for universal items", async () => {
const inputConfig = {
registries: {
"@acme": "https://acme.com/{name}.json",
},
}
const universalConfig = {
...projectConfig,
resolvedPaths: {
...projectConfig.resolvedPaths,
cwd: "/project",
},
registries: inputConfig.registries,
}
const resolvedTree = {
files: [
{
path: "agent.ts",
target: "agent/extensions/agent.ts",
type: "registry:file",
},
],
}
mockConfigWithDefaults.mockReturnValueOnce(universalConfig)
mockResolveRegistryTree.mockResolvedValueOnce(resolvedTree)
await addRegistryItems(["@acme/agent"], {
cwd: "/project",
config: inputConfig,
})
expect(mockConfigWithDefaults).toHaveBeenCalledWith({
registries: inputConfig.registries,
resolvedPaths: { cwd: "/project" },
})
expect(mockResolveRegistryTree).toHaveBeenCalledWith(
["@acme/agent"],
universalConfig,
{ requireUniversal: true, useCache: true }
)
expect(mockAddComponents).toHaveBeenCalledWith(
["@acme/agent"],
universalConfig,
{
interactive: false,
overwriteCssVars: false,
resolvedTree,
}
)
})
it("uses default config for universal URL items", async () => {
const universalConfig = {
...projectConfig,
resolvedPaths: {
...projectConfig.resolvedPaths,
cwd: "/project",
},
}
const resolvedTree = {
files: [
{
path: "agent.ts",
target: "agent/extensions/agent.ts",
type: "registry:file",
},
],
}
mockConfigWithDefaults.mockReturnValueOnce(universalConfig)
mockResolveRegistryTree.mockResolvedValueOnce(resolvedTree)
await addRegistryItems(["https://example.com/agent.json"], {
cwd: "/project",
})
expect(mockConfigWithDefaults).toHaveBeenCalledWith({
resolvedPaths: { cwd: "/project" },
})
expect(mockResolveRegistryTree).toHaveBeenCalledWith(
["https://example.com/agent.json"],
universalConfig,
{ requireUniversal: true, useCache: true }
)
expect(mockAddComponents).toHaveBeenCalledWith(
["https://example.com/agent.json"],
universalConfig,
{
interactive: false,
overwriteCssVars: false,
resolvedTree,
}
)
})
it("uses the config cwd when cwd is omitted", async () => {
await addRegistryItems(["@acme/button"], {
config: projectConfig,
})
expect(mockLoadEnvFiles).toHaveBeenCalledWith("/project", {
processEnv: expect.any(Object),
})
})
it("defaults cwd to process.cwd()", async () => {
const cwd = "/default/project"
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwd)
try {
await addRegistryItems(["https://example.com/agent.json"])
} finally {
cwdSpy.mockRestore()
}
expect(mockLoadEnvFiles).toHaveBeenCalledWith(cwd, {
processEnv: expect.any(Object),
})
expect(mockConfigWithDefaults).toHaveBeenCalledWith({
resolvedPaths: { cwd },
})
})
it("rejects a cwd that conflicts with a full project config", async () => {
await expect(
addRegistryItems(["@acme/button"], {
cwd: "/other-project",
config: projectConfig,
})
).rejects.toThrow("The provided cwd must match config.resolvedPaths.cwd.")
expect(mockLoadEnvFiles).not.toHaveBeenCalled()
expect(mockResolveRegistryTree).not.toHaveBeenCalled()
expect(mockAddComponents).not.toHaveBeenCalled()
})
it("rejects empty cwd values that conflict with a full project config", async () => {
await expect(
addRegistryItems(["@acme/button"], {
cwd: "",
config: projectConfig,
})
).rejects.toThrow("The provided cwd must match config.resolvedPaths.cwd.")
await expect(
addRegistryItems(["@acme/button"], {
cwd: "/project",
config: {
...projectConfig,
resolvedPaths: {
...projectConfig.resolvedPaths,
cwd: "",
},
},
})
).rejects.toThrow("The provided cwd must match config.resolvedPaths.cwd.")
expect(mockLoadEnvFiles).not.toHaveBeenCalled()
expect(mockResolveRegistryTree).not.toHaveBeenCalled()
expect(mockAddComponents).not.toHaveBeenCalled()
})
it("reports partial configs with resolvedPaths as invalid", async () => {
await expect(
addRegistryItems(["@acme/agent"], {
cwd: "/other-project",
config: {
registries: projectConfig.registries,
resolvedPaths: projectConfig.resolvedPaths,
},
})
).rejects.toBeInstanceOf(ConfigParseError)
expect(mockLoadEnvFiles).not.toHaveBeenCalled()
expect(mockResolveRegistryTree).not.toHaveBeenCalled()
expect(mockAddComponents).not.toHaveBeenCalled()
})
it("reports invalid attempted full configs", async () => {
const invalidConfig = {
...projectConfig,
unexpected: true,
}
await expect(
addRegistryItems(["@acme/button"], {
cwd: "/project",
config: invalidConfig,
})
).rejects.toBeInstanceOf(ConfigParseError)
expect(mockConfigWithDefaults).not.toHaveBeenCalled()
expect(mockLoadEnvFiles).not.toHaveBeenCalled()
expect(mockResolveRegistryTree).not.toHaveBeenCalled()
expect(mockAddComponents).not.toHaveBeenCalled()
})
it("reports missing fields in attempted full configs", async () => {
const invalidConfig = {
...projectConfig,
tailwind: {
config: "tailwind.config.ts",
css: "app/globals.css",
cssVariables: true,
},
}
await expect(
addRegistryItems(["@acme/button"], {
cwd: "/project",
config: invalidConfig as never,
})
).rejects.toThrow("tailwind.baseColor")
})
it("reports invalid resolvedPaths cwd values", async () => {
const invalidConfig = {
...projectConfig,
resolvedPaths: {
...projectConfig.resolvedPaths,
cwd: 42,
},
}
await expect(
addRegistryItems(["@acme/button"], {
config: invalidConfig as never,
})
).rejects.toBeInstanceOf(ConfigParseError)
expect(mockLoadEnvFiles).not.toHaveBeenCalled()
expect(mockResolveRegistryTree).not.toHaveBeenCalled()
expect(mockAddComponents).not.toHaveBeenCalled()
})
it("rejects non-universal items without a full project config", async () => {
const universalConfig = {
...projectConfig,
resolvedPaths: {
...projectConfig.resolvedPaths,
cwd: "/project",
},
}
mockConfigWithDefaults.mockReturnValueOnce(universalConfig)
mockResolveRegistryTree.mockRejectedValueOnce(
new Error(
"A full project config is required to add non-universal registry items or dependencies."
)
)
await expect(
addRegistryItems(["@acme/button"], {
cwd: "/project",
config: {
registries: projectConfig.registries,
},
})
).rejects.toThrow("A full project config is required")
expect(mockAddComponents).not.toHaveBeenCalled()
})
it("rejects unresolved target aliases without a full project config", async () => {
const universalConfig = {
...projectConfig,
resolvedPaths: {
...projectConfig.resolvedPaths,
cwd: "/project",
ui: "",
},
}
mockConfigWithDefaults.mockReturnValueOnce(universalConfig)
mockResolveRegistryTree.mockResolvedValueOnce({
files: [
{
path: "agent.ts",
target: "@ui/agent.ts",
type: "registry:file",
},
],
})
await expect(
addRegistryItems(["https://example.com/agent.json"], {
cwd: "/project",
})
).rejects.toThrow(
"A full project config is required to resolve target aliases."
)
expect(mockAddComponents).not.toHaveBeenCalled()
})
it("reports unresolved registry items without a full project config", async () => {
const universalConfig = {
...projectConfig,
resolvedPaths: {
...projectConfig.resolvedPaths,
cwd: "/project",
},
}
mockConfigWithDefaults.mockReturnValueOnce(universalConfig)
mockResolveRegistryTree.mockResolvedValueOnce(null)
await expect(
addRegistryItems(["https://example.com/missing.json"], {
cwd: "/project",
})
).rejects.toThrow("Failed to fetch components from registry.")
expect(mockAddComponents).not.toHaveBeenCalled()
})
it("propagates installation errors", async () => {
mockAddComponents.mockRejectedValueOnce(new Error("Installation failed."))
await expect(
addRegistryItems(["@acme/button"], {
cwd: "/project",
config: projectConfig,
})
).rejects.toThrow("Installation failed.")
})
it("does nothing when no items are provided", async () => {
await addRegistryItems([], {
cwd: "/project",
config: projectConfig,
})
expect(mockLoadEnvFiles).not.toHaveBeenCalled()
expect(mockAddComponents).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,113 @@
import path from "path"
import { configWithDefaults } from "@/src/registry/config"
import { withRegistryContext } from "@/src/registry/context"
import { ConfigParseError } from "@/src/registry/errors"
import { resolveRegistryTree } from "@/src/registry/resolver"
import { configSchema } from "@/src/schema"
import { addComponents } from "@/src/utils/add-components"
import { loadEnvFiles } from "@/src/utils/env-loader"
import { type Config } from "@/src/utils/get-config"
import { getTargetAliasKey } from "@/src/utils/target-aliases"
export async function addRegistryItems(
items: string[],
options: {
cwd?: string
config?: Partial<Config>
overwrite?: boolean
overwriteCssVars?: boolean
silent?: boolean
skipFonts?: boolean
path?: string
} = {}
): Promise<void> {
if (items.length === 0) {
return
}
const {
config: inputConfig,
cwd: inputCwd,
...addComponentsOptions
} = options
const parsedConfig = configSchema.safeParse(inputConfig)
if (!parsedConfig.success && inputConfig && "resolvedPaths" in inputConfig) {
const configCwd =
typeof inputConfig.resolvedPaths?.cwd === "string"
? inputConfig.resolvedPaths.cwd
: undefined
throw new ConfigParseError(
path.resolve(inputCwd ?? configCwd ?? process.cwd()),
parsedConfig.error,
"config"
)
}
const configCwd = parsedConfig.success
? parsedConfig.data.resolvedPaths.cwd
: undefined
const cwd = path.resolve(inputCwd ?? configCwd ?? process.cwd())
if (
inputCwd !== undefined &&
configCwd !== undefined &&
cwd !== path.resolve(configCwd)
) {
throw new Error("The provided cwd must match config.resolvedPaths.cwd.")
}
const config = configWithDefaults(
parsedConfig.success
? parsedConfig.data
: {
...inputConfig,
resolvedPaths: {
...inputConfig?.resolvedPaths,
cwd,
},
}
)
const env = await loadEnvFiles(cwd, {
processEnv: { ...process.env },
})
return withRegistryContext(
async () => {
const resolvedTree = await resolveRegistryTree(items, config, {
useCache: true,
requireUniversal: !parsedConfig.success,
})
if (!resolvedTree) {
throw new Error("Failed to fetch components from registry.")
}
if (
!parsedConfig.success &&
!hasResolvedTargetAliases(resolvedTree, config)
) {
throw new Error(
"A full project config is required to resolve target aliases."
)
}
await addComponents(items, config, {
...addComponentsOptions,
interactive: false,
overwriteCssVars:
options.overwriteCssVars ??
(parsedConfig.success ? undefined : false),
resolvedTree,
})
},
{ env }
)
}
function hasResolvedTargetAliases(
registryTree: NonNullable<Awaited<ReturnType<typeof resolveRegistryTree>>>,
config: Config
) {
return (registryTree.files ?? []).every((file) => {
const aliasKey = getTargetAliasKey(file.target)
return !aliasKey || Boolean(config.resolvedPaths[aliasKey])
})
}

View File

@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest"
import { resolveGitHubRegistrySource, resolveItemAddress } from "./address"
import { RegistryValidationError } from "./errors"
describe("resolveItemAddress", () => {
it.each([
["button", { scheme: "shadcn", item: "button" }],
["calendar", { scheme: "shadcn", item: "calendar" }],
])("resolves shadcn item address %s", (input, expected) => {
expect(resolveItemAddress(input)).toEqual(expected)
})
it.each([
[
"@acme/button",
{ scheme: "namespace", namespace: "@acme", item: "button" },
],
[
"@acme/ui/button",
{ scheme: "namespace", namespace: "@acme", item: "ui/button" },
],
])("resolves namespace item address %s", (input, expected) => {
expect(resolveItemAddress(input)).toEqual(expected)
})
it.each([
[
"https://example.com/r/button.json",
{ scheme: "url", url: "https://example.com/r/button.json" },
],
[
"https://example.com/r/button.json#fragment",
{ scheme: "url", url: "https://example.com/r/button.json#fragment" },
],
])("resolves url item address %s", (input, expected) => {
expect(resolveItemAddress(input)).toEqual(expected)
})
it.each([
["./button.json", { scheme: "file", path: "./button.json" }],
[
"../registry/button.json",
{ scheme: "file", path: "../registry/button.json" },
],
[
"/Users/me/registry/button.json",
{ scheme: "file", path: "/Users/me/registry/button.json" },
],
])("resolves file item address %s", (input, expected) => {
expect(resolveItemAddress(input)).toEqual(expected)
})
it.each([
[
"acme/ui/button",
{
scheme: "github",
owner: "acme",
repo: "ui",
item: "button",
},
],
[
"acme/ui/forms/login",
{
scheme: "github",
owner: "acme",
repo: "ui",
item: "forms/login",
},
],
[
"acme/ui/button#v1.2.0",
{
scheme: "github",
owner: "acme",
repo: "ui",
item: "button",
ref: "v1.2.0",
},
],
[
"acme/ui/button#feature/login-form",
{
scheme: "github",
owner: "acme",
repo: "ui",
item: "button",
ref: "feature/login-form",
},
],
])("resolves GitHub item address %s", (input, expected) => {
expect(resolveItemAddress(input)).toEqual(expected)
})
it.each([
["foo/bar", { scheme: "shadcn", item: "foo/bar" }],
["-owner/repo/button", { scheme: "shadcn", item: "-owner/repo/button" }],
["owner-/repo/button", { scheme: "shadcn", item: "owner-/repo/button" }],
["owner/./button", { scheme: "shadcn", item: "owner/./button" }],
["owner/../button", { scheme: "shadcn", item: "owner/../button" }],
[
"owner/repo with space/button",
{ scheme: "shadcn", item: "owner/repo with space/button" },
],
])(
"does not classify invalid GitHub addresses as GitHub: %s",
(input, expected) => {
expect(resolveItemAddress(input)).toEqual(expected)
}
)
it("keeps .json addresses classified as file paths", () => {
expect(resolveItemAddress("owner/repo/data/schema.json")).toEqual({
scheme: "file",
path: "owner/repo/data/schema.json",
})
})
it("rejects empty GitHub refs", () => {
expect(() => resolveItemAddress("acme/ui/button#")).toThrow(
RegistryValidationError
)
})
it("rejects GitHub refs with control characters", () => {
expect(() => resolveItemAddress("acme/ui/button#bad\nref")).toThrow(
RegistryValidationError
)
})
it("rejects GitHub refs with whitespace", () => {
expect(() => resolveItemAddress("acme/ui/button#my tag")).toThrow(
RegistryValidationError
)
})
it("rejects GitHub refs that look like git options", () => {
expect(() =>
resolveItemAddress("acme/ui/button#--upload-pack=/bin/false")
).toThrow(RegistryValidationError)
})
})
describe("resolveGitHubRegistrySource", () => {
it.each([
["acme/ui", { owner: "acme", repo: "ui" }],
["acme/ui#v1.0.0", { owner: "acme", repo: "ui", ref: "v1.0.0" }],
[
"acme/ui#feature/forms",
{ owner: "acme", repo: "ui", ref: "feature/forms" },
],
])("resolves GitHub registry source %s", (input, expected) => {
expect(resolveGitHubRegistrySource(input)).toEqual(expected)
})
it.each([
"acme",
"acme/ui/button",
"@acme/ui",
"https://example.com",
"owner/.",
"owner/..",
])("does not resolve non-GitHub registry source %s", (input) => {
expect(resolveGitHubRegistrySource(input)).toBeNull()
})
it("rejects refs that look like git options", () => {
expect(() =>
resolveGitHubRegistrySource("acme/ui#--upload-pack=/bin/false")
).toThrow(RegistryValidationError)
})
it("rejects refs with whitespace", () => {
expect(() => resolveGitHubRegistrySource("acme/ui#my tag")).toThrow(
RegistryValidationError
)
})
})

View File

@@ -0,0 +1,182 @@
import { RegistryValidationError } from "@/src/registry/errors"
import { parseRegistryAndItemFromString } from "@/src/registry/parser"
import { isLocalFile, isUrl } from "@/src/registry/utils"
const GITHUB_OWNER_PATTERN =
/^(?!.*--)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/
const GITHUB_REPO_PATTERN = /^[a-zA-Z0-9._-]+$/
const INVALID_GITHUB_REPO_NAMES = new Set([".", ".."])
const CONTROL_CHARACTER_PATTERN = /[\x00-\x1F\x7F]/
const WHITESPACE_PATTERN = /\s/
const GITHUB_REF_OPTION_PATTERN = /^-/
export type ResolvedItemAddress =
| {
scheme: "shadcn"
item: string
}
| {
scheme: "namespace"
namespace: string
item: string
}
| {
scheme: "url"
url: string
}
| {
scheme: "file"
path: string
}
| {
scheme: "github"
owner: string
repo: string
item: string
ref?: string
}
export type ResolvedGitHubRegistrySource = {
owner: string
repo: string
ref?: string
}
export function resolveItemAddress(address: string) {
if (isUrl(address)) {
return {
scheme: "url",
url: address,
} satisfies ResolvedItemAddress
}
if (isLocalFile(address)) {
return {
scheme: "file",
path: address,
} satisfies ResolvedItemAddress
}
const { registry, item } = parseRegistryAndItemFromString(address)
if (registry) {
return {
scheme: "namespace",
namespace: registry,
item,
} satisfies ResolvedItemAddress
}
const githubAddress = resolveGitHubItemAddress(address)
if (githubAddress) {
return githubAddress
}
return {
scheme: "shadcn",
item: address,
} satisfies ResolvedItemAddress
}
export function isGitHubItemAddress(address: string) {
return resolveItemAddress(address).scheme === "github"
}
export function resolveGitHubRegistrySource(source: string) {
const hashIndex = source.indexOf("#")
const path = hashIndex === -1 ? source : source.slice(0, hashIndex)
const ref = hashIndex === -1 ? undefined : source.slice(hashIndex + 1)
const parts = path.split("/")
if (parts.length !== 2) {
return null
}
const [owner, repo] = parts
if (!isGitHubOwner(owner) || !isGitHubRepo(repo)) {
return null
}
if (ref !== undefined && !isValidGitHubRef(ref)) {
throw new RegistryValidationError(
`Invalid GitHub ref in registry source "${source}".`,
{
context: {
source,
ref,
},
suggestion:
"Use a non-empty branch, tag, or commit SHA without whitespace, control characters or leading dashes.",
}
)
}
return {
owner,
repo,
ref,
} satisfies ResolvedGitHubRegistrySource
}
export function isGitHubRegistrySource(source: string) {
return resolveGitHubRegistrySource(source) !== null
}
function resolveGitHubItemAddress(address: string) {
const hashIndex = address.indexOf("#")
const source = hashIndex === -1 ? address : address.slice(0, hashIndex)
const ref = hashIndex === -1 ? undefined : address.slice(hashIndex + 1)
const parts = source.split("/")
if (parts.length < 3) {
return null
}
const [owner, repo, ...itemParts] = parts
if (!isGitHubOwner(owner) || !isGitHubRepo(repo)) {
return null
}
const item = itemParts.join("/")
if (!item) {
return null
}
if (ref !== undefined && !isValidGitHubRef(ref)) {
throw new RegistryValidationError(
`Invalid GitHub ref in item address "${address}".`,
{
context: {
address,
ref,
},
suggestion:
"Use a non-empty branch, tag, or commit SHA without whitespace, control characters or leading dashes.",
}
)
}
return {
scheme: "github",
owner,
repo,
item,
ref,
} satisfies Extract<ResolvedItemAddress, { scheme: "github" }>
}
function isGitHubOwner(owner: string) {
return GITHUB_OWNER_PATTERN.test(owner)
}
function isGitHubRepo(repo: string) {
return GITHUB_REPO_PATTERN.test(repo) && !INVALID_GITHUB_REPO_NAMES.has(repo)
}
function isValidGitHubRef(ref: string) {
return (
!!ref &&
!CONTROL_CHARACTER_PATTERN.test(ref) &&
!WHITESPACE_PATTERN.test(ref) &&
!GITHUB_REF_OPTION_PATTERN.test(ref)
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,456 @@
import { existsSync } from "fs"
import path from "path"
import { resolveGitHubRegistrySource } from "@/src/registry/address"
import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder"
import { configWithDefaults } from "@/src/registry/config"
import {
BASE_COLORS,
BUILTIN_REGISTRIES,
REGISTRY_URL,
} from "@/src/registry/constants"
import { setRegistryHeaders, withRegistryContext } from "@/src/registry/context"
import {
ConfigParseError,
RegistriesIndexParseError,
RegistryInvalidNamespaceError,
RegistryNotFoundError,
RegistryParseError,
RegistryValidationError,
} from "@/src/registry/errors"
import { fetchRegistry } from "@/src/registry/fetcher"
import { fetchGitHubRegistryCatalog } from "@/src/registry/github"
import {
fetchRegistryItems,
resolveRegistryTree,
} from "@/src/registry/resolver"
import { isUrl } from "@/src/registry/utils"
import {
configJsonSchema,
iconsSchema,
registriesIndexSchema,
registriesSchema,
registryBaseColorSchema,
registryConfigSchema,
registryIndexSchema,
registryItemSchema,
registrySchema,
stylesSchema,
} from "@/src/schema"
import { Config, explorer } from "@/src/utils/get-config"
import { handleError } from "@/src/utils/handle-error"
import { logger } from "@/src/utils/logger"
import { cosmiconfig } from "cosmiconfig"
import { z } from "zod"
const packageRegistriesExplorer = cosmiconfig("registries", {
packageProp: "registries",
searchPlaces: ["package.json"],
})
const registriesConfigFileSchema = z.object({
registries: registryConfigSchema.optional(),
})
type RegistryApiOptions = {
config?: Partial<Config>
useCache?: boolean
}
// Search parameters forwarded to the registry as query params so dynamic
// registries can filter server-side. Static registries ignore them and return
// the full catalog. See https://ui.shadcn.com/docs/registry/dynamic-search.
type RegistrySearchParams = {
query?: string
types?: string[]
limit?: number
offset?: number
}
type GetRegistryOptions = RegistryApiOptions & {
searchParams?: RegistrySearchParams
}
function appendSearchParamsToUrl(
url: string,
searchParams?: RegistrySearchParams
) {
if (!searchParams) {
return url
}
const parsedUrl = new URL(url)
if (searchParams.query) {
parsedUrl.searchParams.set("q", searchParams.query)
}
if (searchParams.types?.length) {
parsedUrl.searchParams.set("type", searchParams.types.join(","))
}
if (searchParams.limit !== undefined) {
parsedUrl.searchParams.set("limit", String(searchParams.limit))
}
if (searchParams.offset !== undefined) {
parsedUrl.searchParams.set("offset", String(searchParams.offset))
}
return parsedUrl.toString()
}
export async function getRegistry(name: string, options?: GetRegistryOptions) {
return withRegistryContext(() => getRegistryWithContext(name, options))
}
async function getRegistryWithContext(
name: string,
options?: GetRegistryOptions
) {
const { config, useCache, searchParams } = options || {}
if (isUrl(name)) {
const url = appendSearchParamsToUrl(name, searchParams)
const [result] = await fetchRegistry([url], { useCache })
return parseRegistryCatalog(name, result)
}
// GitHub registries are raw files. There is no server to run a search, so
// search params are not forwarded and filtering happens locally.
const githubSource = resolveGitHubRegistrySource(name)
if (githubSource) {
return fetchGitHubRegistryCatalog(githubSource, { useCache })
}
if (!name.startsWith("@")) {
throw new RegistryInvalidNamespaceError(name)
}
let registryName = name
if (!registryName.endsWith("/registry")) {
registryName = `${registryName}/registry`
}
const urlAndHeaders = buildUrlAndHeadersForRegistryItem(
registryName as `@${string}`,
configWithDefaults(config)
)
if (!urlAndHeaders?.url) {
throw new RegistryNotFoundError(registryName)
}
// Append search params before registering headers so the header lookup key
// matches the URL we actually fetch.
const url = appendSearchParamsToUrl(urlAndHeaders.url, searchParams)
if (urlAndHeaders.headers && Object.keys(urlAndHeaders.headers).length > 0) {
setRegistryHeaders({
[url]: urlAndHeaders.headers,
})
}
const [result] = await fetchRegistry([url], { useCache })
return parseRegistryCatalog(registryName, result)
}
function parseRegistryCatalog(name: string, result: unknown) {
try {
const registry = registrySchema.parse(result)
if (registry.include?.length) {
throw new RegistryValidationError(
`Registry catalog "${name}" uses "include", but consumer registry endpoints must serve a resolved registry catalog. Run "npx shadcn build" and serve the built registry.json, or use loadRegistry() in a dynamic route.`,
{
context: {
registry: name,
include: registry.include,
},
suggestion:
"Serve a flattened registry.json for CLI consumers. Source registry.json files with include are supported by shadcn build and loadRegistry().",
}
)
}
return registry
} catch (error) {
if (error instanceof RegistryValidationError) {
throw error
}
throw new RegistryParseError(name, error, {
subject: "registry catalog",
suggestion:
"The registry catalog may be corrupted or have an invalid format. Please make sure it returns a valid registry.json object. See https://ui.shadcn.com/schema/registry.json.",
})
}
}
export async function getRegistryItems(
items: string[],
options?: RegistryApiOptions
) {
const { config, useCache = false } = options || {}
return withRegistryContext(() =>
fetchRegistryItems(items, configWithDefaults(config), { useCache })
)
}
export async function resolveRegistryItems(
items: string[],
options?: RegistryApiOptions
) {
const { config, useCache = false } = options || {}
return withRegistryContext(() =>
resolveRegistryTree(items, configWithDefaults(config), { useCache })
)
}
export async function getRegistriesConfig(
cwd: string,
options?: { useCache?: boolean }
) {
const { useCache = true } = options || {}
if (!useCache) {
explorer.clearCaches()
packageRegistriesExplorer.clearCaches()
}
const packageJsonRegistries = await getPackageJsonRegistries(cwd)
// Registries are merged from package.json and components.json, with
// components.json taking precedence per key.
const componentsJsonPath = path.resolve(cwd, "components.json")
if (existsSync(componentsJsonPath)) {
const configResult = await explorer.load(componentsJsonPath)
const config = parseRegistriesConfig(
cwd,
configResult?.config,
"components.json"
)
return {
registries: {
...BUILTIN_REGISTRIES,
...packageJsonRegistries,
...config.registries,
},
}
}
return {
registries: packageJsonRegistries,
}
}
export async function getPackageJsonRegistries(
cwd: string
): Promise<z.infer<typeof registryConfigSchema>> {
const packageJsonPath = path.resolve(cwd, "package.json")
if (!existsSync(packageJsonPath)) {
return {}
}
const configResult = await packageRegistriesExplorer.load(packageJsonPath)
return parseRegistriesConfig(
cwd,
{
registries: configResult?.config,
},
"package.json"
).registries
}
function parseRegistriesConfig(
cwd: string,
config: unknown,
configFile: "components.json" | "package.json"
) {
const result = registriesConfigFileSchema.safeParse(config)
if (!result.success) {
throw new ConfigParseError(cwd, result.error, configFile)
}
return {
registries: result.data.registries || {},
}
}
export async function getShadcnRegistryIndex() {
const [result] = await fetchRegistry(["index.json"])
return registryIndexSchema.parse(result)
}
export async function getRegistryStyles() {
try {
const [result] = await fetchRegistry(["styles/index.json"])
return stylesSchema.parse(result)
} catch (error) {
logger.error("\n")
handleError(error)
return []
}
}
export async function getRegistryIcons() {
try {
const [result] = await fetchRegistry(["icons/index.json"])
return iconsSchema.parse(result)
} catch (error) {
handleError(error)
return {}
}
}
export async function getRegistryBaseColors() {
return BASE_COLORS
}
export async function getRegistryBaseColor(baseColor: string) {
const [result] = await fetchRegistry([`colors/${baseColor}.json`])
return registryBaseColorSchema.parse(result)
}
/**
* @deprecated This function is deprecated and will be removed in a future version.
*/
export async function resolveTree(
index: z.infer<typeof registryIndexSchema>,
names: string[]
) {
const tree: z.infer<typeof registryIndexSchema> = []
for (const name of names) {
const entry = index.find((entry) => entry.name === name)
if (!entry) {
continue
}
tree.push(entry)
if (entry.registryDependencies) {
const dependencies = await resolveTree(index, entry.registryDependencies)
tree.push(...dependencies)
}
}
return tree.filter(
(component, index, self) =>
self.findIndex((c) => c.name === component.name) === index
)
}
/**
* @deprecated This function is deprecated and will be removed in a future version.
*/
export async function fetchTree(
style: string,
tree: z.infer<typeof registryIndexSchema>
) {
try {
const paths = tree.map((item) => `styles/${style}/${item.name}.json`)
const results = await fetchRegistry(paths)
return results.map((result) => registryItemSchema.parse(result))
} catch (error) {
handleError(error)
return []
}
}
/**
* @deprecated This function is deprecated and will be removed in a future version.
*/
export async function getItemTargetPath(
config: Config,
item: Pick<z.infer<typeof registryItemSchema>, "type">,
override?: string
) {
if (override) {
return override
}
if (item.type === "registry:ui") {
return config.resolvedPaths.ui ?? config.resolvedPaths.components
}
const [parent, type] = item.type?.split(":") ?? []
if (!(parent in config.resolvedPaths)) {
return null
}
return path.join(
config.resolvedPaths[parent as keyof typeof config.resolvedPaths],
type
)
}
// Fetch registries with new schema (array of objects with name, homepage, url, featured).
export async function getRegistries(options?: { useCache?: boolean }) {
options = {
useCache: true,
...options,
}
const url = `${REGISTRY_URL}/registries.json`
const [data] = await fetchRegistry([url], {
useCache: options.useCache,
})
try {
return registriesSchema.parse(data)
} catch (error) {
if (error instanceof z.ZodError) {
throw new RegistriesIndexParseError(error)
}
throw error
}
}
/**
* @deprecated Use getRegistries() instead.
*/
export async function getRegistriesIndex(options?: { useCache?: boolean }) {
// Fetch new format and transform to old Record<string, string> for backward compatibility.
const registries = await getRegistries(options)
if (!registries) return null
return Object.fromEntries(registries.map((r) => [r.name, r.url])) as z.infer<
typeof registriesIndexSchema
>
}
export async function getPresets(options?: { useCache?: boolean }) {
options = {
useCache: true,
...options,
}
const url = `${REGISTRY_URL}/config.json`
const [data] = await fetchRegistry([url], {
useCache: options.useCache,
})
const result = configJsonSchema.parse(data)
return result.presets
}
export async function getPreset(
name: string,
options?: { useCache?: boolean }
) {
const presets = await getPresets(options)
return (
presets.find(
(preset) => preset.name.toLowerCase() === name.toLowerCase()
) ?? null
)
}

View File

@@ -0,0 +1,492 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { REGISTRY_URL } from "@/src/registry/constants"
import { RegistryNotConfiguredError } from "@/src/registry/errors"
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import {
buildHeadersFromRegistryConfig,
buildUrlAndHeadersForRegistryItem,
buildUrlFromRegistryConfig,
resolveRegistryUrl,
} from "./builder"
describe("buildUrlFromRegistryConfig", () => {
beforeEach(() => {
process.env.TEST_TOKEN = "abc123"
process.env.API_VERSION = "v2"
process.env.API_KEY = "key456"
})
afterEach(() => {
delete process.env.TEST_TOKEN
delete process.env.API_VERSION
delete process.env.API_KEY
})
it("should build URL from string config", () => {
const url = buildUrlFromRegistryConfig(
"chat-component",
"https://v0.dev/chat/b/{name}/json"
)
expect(url).toBe("https://v0.dev/chat/b/chat-component/json")
})
it("should replace style placeholder in URL", () => {
const url = buildUrlFromRegistryConfig(
"button",
"https://ui.shadcn.com/r/styles/{style}/{name}.json",
{ style: "new-york" } as any
)
expect(url).toBe("https://ui.shadcn.com/r/styles/new-york/button.json")
})
it("should handle both name and style placeholders", () => {
const url = buildUrlFromRegistryConfig(
"accordion",
"https://example.com/{style}/components/{name}",
{ style: "default" } as any
)
expect(url).toBe("https://example.com/default/components/accordion")
})
it("should build URL with env vars", () => {
const url = buildUrlFromRegistryConfig(
"button",
"https://api.com/{name}?token=${TEST_TOKEN}"
)
expect(url).toBe("https://api.com/button?token=abc123")
})
it("should build URL with params", () => {
const config = {
url: "https://api.com/{name}",
params: {
version: "${API_VERSION}",
format: "json",
},
}
const url = buildUrlFromRegistryConfig("table", config)
expect(url).toBe("https://api.com/table?version=v2&format=json")
})
it("should skip empty param values", () => {
const config = {
url: "https://api.com/{name}",
params: {
token: "${MISSING_VAR}",
format: "json",
},
}
const url = buildUrlFromRegistryConfig("table", config)
expect(url).toBe("https://api.com/table?format=json")
})
it("should handle existing query params", () => {
const config = {
url: "https://api.com/{name}?existing=true",
params: {
new: "param",
},
}
const url = buildUrlFromRegistryConfig("table", config)
expect(url).toBe("https://api.com/table?existing=true&new=param")
})
it("should handle URL with no params", () => {
const config = {
url: "https://api.com/{name}",
}
const url = buildUrlFromRegistryConfig("table", config)
expect(url).toBe("https://api.com/table")
})
it("should handle multiple env vars in params", () => {
const config = {
url: "https://api.com/{name}",
params: {
token: "${TEST_TOKEN}",
version: "${API_VERSION}",
key: "${API_KEY}",
},
}
const url = buildUrlFromRegistryConfig("table", config)
expect(url).toBe("https://api.com/table?token=abc123&version=v2&key=key456")
})
it("should handle all empty env vars in params", () => {
const config = {
url: "https://api.com/{name}",
params: {
token: "${MISSING_VAR1}",
key: "${MISSING_VAR2}",
},
}
const url = buildUrlFromRegistryConfig("table", config)
expect(url).toBe("https://api.com/table")
})
it("should handle mixed static and env var params", () => {
const config = {
url: "https://api.com/{name}",
params: {
static: "value",
env: "${TEST_TOKEN}",
empty: "${MISSING_VAR}",
},
}
const url = buildUrlFromRegistryConfig("table", config)
expect(url).toBe("https://api.com/table?static=value&env=abc123")
})
it("should handle special characters in params", () => {
const config = {
url: "https://api.com/{name}",
params: {
"user-id": "123",
"api-key": "${TEST_TOKEN}",
"content-type": "application/json",
},
}
const url = buildUrlFromRegistryConfig("table", config)
expect(url).toBe(
"https://api.com/table?user-id=123&api-key=abc123&content-type=application%2Fjson"
)
})
it("should handle URL with multiple existing query params", () => {
const config = {
url: "https://api.com/{name}?param1=value1&param2=value2",
params: {
newParam: "newValue",
envParam: "${TEST_TOKEN}",
},
}
const url = buildUrlFromRegistryConfig("table", config)
expect(url).toBe(
"https://api.com/table?param1=value1&param2=value2&newParam=newValue&envParam=abc123"
)
})
})
describe("buildHeadersFromRegistryConfig", () => {
beforeEach(() => {
process.env.AUTH_TOKEN = "secret123"
process.env.CLIENT_ID = "client456"
process.env.API_KEY = "key789"
})
afterEach(() => {
delete process.env.AUTH_TOKEN
delete process.env.CLIENT_ID
delete process.env.API_KEY
})
it("should return empty object for string config", () => {
expect(buildHeadersFromRegistryConfig("https://api.com/{name}")).toEqual({})
})
it("should return empty object for config without headers", () => {
expect(
buildHeadersFromRegistryConfig({ url: "https://api.com/{name}" })
).toEqual({})
})
it("should expand headers with env vars", () => {
const config = {
url: "https://api.com/{name}",
headers: {
Authorization: "Bearer ${AUTH_TOKEN}",
"X-Client-Id": "${CLIENT_ID}",
},
}
expect(buildHeadersFromRegistryConfig(config)).toEqual({
Authorization: "Bearer secret123",
"X-Client-Id": "client456",
})
})
it("should skip headers with empty values", () => {
const config = {
url: "https://api.com/{name}",
headers: {
Authorization: "Bearer ${MISSING_VAR}",
"X-Client-Id": "${CLIENT_ID}",
},
}
expect(buildHeadersFromRegistryConfig(config)).toEqual({
"X-Client-Id": "client456",
})
})
it("should handle headers with mixed static and env var content", () => {
const config = {
url: "https://api.com/{name}",
headers: {
Authorization: "Bearer ${AUTH_TOKEN}",
"Content-Type": "application/json",
"X-Custom": "prefix-${CLIENT_ID}-suffix",
},
}
expect(buildHeadersFromRegistryConfig(config)).toEqual({
Authorization: "Bearer secret123",
"Content-Type": "application/json",
"X-Custom": "prefix-client456-suffix",
})
})
it("should skip headers with only env vars that are empty", () => {
const config = {
url: "https://api.com/{name}",
headers: {
"X-Missing": "${MISSING_VAR1}",
"X-Also-Missing": "${MISSING_VAR2}",
"X-Present": "${CLIENT_ID}",
},
}
expect(buildHeadersFromRegistryConfig(config)).toEqual({
"X-Present": "client456",
})
})
it("should handle headers with whitespace-only values", () => {
const config = {
url: "https://api.com/{name}",
headers: {
"X-Empty": " ",
"X-Whitespace": " ${MISSING_VAR} ",
"X-Valid": " ${CLIENT_ID} ",
},
}
expect(buildHeadersFromRegistryConfig(config)).toEqual({
"X-Valid": " client456 ",
})
})
it("should handle complex env var patterns", () => {
const config = {
url: "https://api.com/{name}",
headers: {
"X-Complex": "Bearer ${AUTH_TOKEN} with ${CLIENT_ID} and ${API_KEY}",
"X-Simple": "${CLIENT_ID}",
"X-Mixed": "static-${AUTH_TOKEN}-${MISSING_VAR}-${API_KEY}",
},
}
expect(buildHeadersFromRegistryConfig(config)).toEqual({
"X-Complex": "Bearer secret123 with client456 and key789",
"X-Simple": "client456",
"X-Mixed": "static-secret123--key789",
})
})
it("should handle headers with only static content", () => {
const config = {
url: "https://api.com/{name}",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": "shadcn-ui/1.0.0",
},
}
expect(buildHeadersFromRegistryConfig(config)).toEqual({
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": "shadcn-ui/1.0.0",
})
})
it("should handle headers with template-like content but no env vars", () => {
const config = {
url: "https://api.com/{name}",
headers: {
"X-Template": "This is a template ${but not an env var}",
"X-Regular": "Regular header value",
},
}
expect(buildHeadersFromRegistryConfig(config)).toEqual({
"X-Template": "This is a template ${but not an env var}",
"X-Regular": "Regular header value",
})
})
it("should handle case where env var expansion doesn't change the value", () => {
const config = {
url: "https://api.com/{name}",
headers: {
"X-No-Change": "static value",
"X-With-Env": "prefix-${CLIENT_ID}-suffix",
},
}
expect(buildHeadersFromRegistryConfig(config)).toEqual({
"X-No-Change": "static value",
"X-With-Env": "prefix-client456-suffix",
})
})
})
describe("buildUrlAndHeadersForRegistryItem", () => {
it("should resolve non-registry items through @shadcn registry", () => {
const input = "button"
const config = {} as any
// Non-prefixed items are resolved through the built-in @shadcn registry
expect(buildUrlAndHeadersForRegistryItem(input, config)).toEqual({
url: "https://ui.shadcn.com/r/styles/{style}/button.json",
headers: {},
})
})
it("should throw error for unknown registry", () => {
try {
buildUrlAndHeadersForRegistryItem("@unknown/button", {} as any)
expect.fail("Should have thrown RegistryNotConfiguredError")
} catch (error) {
expect(error).toBeInstanceOf(RegistryNotConfiguredError)
if (error instanceof RegistryNotConfiguredError) {
expect(error.message).toContain(
'defined under "registries" in your components.json or package.json file'
)
expect(error.suggestion).toBe(
'Add the registry configuration under "registries" in your components.json or package.json file.'
)
}
}
})
it("should resolve registry items with string config", () => {
const config = {
registries: {
"@v0": "https://v0.dev/chat/b/{name}/json",
},
} as any
const result1 = buildUrlAndHeadersForRegistryItem("@v0/button", config)
expect(result1).toEqual({
url: "https://v0.dev/chat/b/button/json",
headers: {},
})
const result2 = buildUrlAndHeadersForRegistryItem("@v0/data-table", config)
expect(result2).toEqual({
url: "https://v0.dev/chat/b/data-table/json",
headers: {},
})
})
it("should resolve registry items with object config", () => {
const config = {
registries: {
"@test": {
url: "https://api.com/{name}.json",
headers: {
Authorization: "Bearer token123",
},
},
},
} as any
const result = buildUrlAndHeadersForRegistryItem("@test/button", config)
expect(result).toEqual({
url: "https://api.com/button.json",
headers: {
Authorization: "Bearer token123",
},
})
})
it("should handle environment variables in config", () => {
process.env.TEST_TOKEN = "abc123"
process.env.API_URL = "https://api.com"
const config = {
registries: {
"@env": {
url: "${API_URL}/{name}.json",
headers: {
Authorization: "Bearer ${TEST_TOKEN}",
},
},
},
} as any
const result = buildUrlAndHeadersForRegistryItem("@env/button", config)
expect(result).toEqual({
url: "https://api.com/button.json",
headers: {
Authorization: "Bearer abc123",
},
})
delete process.env.TEST_TOKEN
delete process.env.API_URL
})
it("should handle complex item paths", () => {
const config = {
registries: {
"@acme": "https://api.com/{name}.json",
},
} as any
const result = buildUrlAndHeadersForRegistryItem("@acme/ui/button", config)
expect(result).toEqual({
url: "https://api.com/ui/button.json",
headers: {},
})
})
it("should handle URLs and local files", () => {
const config = { registries: {} } as any
// URLs should return null (not registry items)
expect(
buildUrlAndHeadersForRegistryItem("https://example.com/button", config)
).toBeNull()
// Local files should return null (not registry items)
expect(
buildUrlAndHeadersForRegistryItem("./local/button", config)
).toBeNull()
})
})
describe("resolveRegistryUrl", () => {
it("should return the URL as-is for valid URLs", () => {
const url = "https://example.com/component.json"
expect(resolveRegistryUrl(url)).toBe(url)
})
it("should append /json to v0 registry URLs", () => {
const v0Url = "https://v0.dev/chat/b/abc123"
expect(resolveRegistryUrl(v0Url)).toBe("https://v0.dev/chat/b/abc123/json")
})
it("should not append /json if already present", () => {
const v0Url = "https://v0.dev/chat/b/abc123/json"
expect(resolveRegistryUrl(v0Url)).toBe(v0Url)
})
it("should prepend REGISTRY_URL for non-URLs", () => {
expect(resolveRegistryUrl("test.json")).toBe(`${REGISTRY_URL}/test.json`)
expect(resolveRegistryUrl("styles/default/button.json")).toBe(
`${REGISTRY_URL}/styles/default/button.json`
)
})
})

View File

@@ -0,0 +1,168 @@
import { isGitHubItemAddress } from "@/src/registry/address"
import { BUILTIN_REGISTRIES, REGISTRY_URL } from "@/src/registry/constants"
import { expandEnvVars } from "@/src/registry/env"
import { RegistryNotConfiguredError } from "@/src/registry/errors"
import { parseRegistryAndItemFromString } from "@/src/registry/parser"
import { isLocalFile, isUrl } from "@/src/registry/utils"
import { validateRegistryConfig } from "@/src/registry/validator"
import { registryConfigItemSchema } from "@/src/schema"
import { Config } from "@/src/utils/get-config"
import { z } from "zod"
const NAME_PLACEHOLDER = "{name}"
const STYLE_PLACEHOLDER = "{style}"
const ENV_VAR_PATTERN = /\${(\w+)}/g
const QUERY_PARAM_SEPARATOR = "?"
const QUERY_PARAM_DELIMITER = "&"
function isLocalPath(path: string) {
return path.startsWith("./") || path.startsWith("/")
}
export function buildUrlAndHeadersForRegistryItem(
name: string,
config?: Config
) {
let { registry, item } = parseRegistryAndItemFromString(name)
// If no registry prefix, check if it's a URL or local path.
// These should be handled directly, not through a registry.
if (!registry) {
if (
isUrl(name) ||
isLocalFile(name) ||
isLocalPath(name) ||
isGitHubItemAddress(name)
) {
return null
}
registry = "@shadcn"
}
const registries = { ...BUILTIN_REGISTRIES, ...config?.registries }
const registryConfig = registries[registry]
if (!registryConfig) {
throw new RegistryNotConfiguredError(registry)
}
// TODO: I don't like this here.
// But this will do for now.
validateRegistryConfig(registry, registryConfig)
return {
url: buildUrlFromRegistryConfig(item, registryConfig, config),
headers: buildHeadersFromRegistryConfig(registryConfig),
}
}
export function buildUrlFromRegistryConfig(
item: string,
registryConfig: z.infer<typeof registryConfigItemSchema>,
config?: Config
) {
if (typeof registryConfig === "string") {
let url = registryConfig.replace(NAME_PLACEHOLDER, item)
if (config?.style && url.includes(STYLE_PLACEHOLDER)) {
url = url.replace(STYLE_PLACEHOLDER, config.style)
}
return expandEnvVars(url)
}
let baseUrl = registryConfig.url.replace(NAME_PLACEHOLDER, item)
if (config?.style && baseUrl.includes(STYLE_PLACEHOLDER)) {
baseUrl = baseUrl.replace(STYLE_PLACEHOLDER, config.style)
}
baseUrl = expandEnvVars(baseUrl)
if (!registryConfig.params) {
return baseUrl
}
return appendQueryParams(baseUrl, registryConfig.params)
}
export function buildHeadersFromRegistryConfig(
config: z.infer<typeof registryConfigItemSchema>
) {
if (typeof config === "string" || !config.headers) {
return {}
}
const headers: Record<string, string> = {}
for (const [key, value] of Object.entries(config.headers)) {
const expandedValue = expandEnvVars(value)
if (shouldIncludeHeader(value, expandedValue)) {
headers[key] = expandedValue
}
}
return headers
}
function appendQueryParams(baseUrl: string, params: Record<string, string>) {
const urlParams = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
const expandedValue = expandEnvVars(value)
if (expandedValue) {
urlParams.append(key, expandedValue)
}
}
const queryString = urlParams.toString()
if (!queryString) {
return baseUrl
}
const separator = baseUrl.includes(QUERY_PARAM_SEPARATOR)
? QUERY_PARAM_DELIMITER
: QUERY_PARAM_SEPARATOR
return `${baseUrl}${separator}${queryString}`
}
function shouldIncludeHeader(originalValue: string, expandedValue: string) {
const trimmedExpanded = expandedValue.trim()
if (!trimmedExpanded) {
return false
}
// If the original value contains valid env vars, only include if expansion changed the value.
if (originalValue.includes("${")) {
// Check if there are actual env vars in the string
const envVars = originalValue.match(ENV_VAR_PATTERN)
if (envVars) {
const templateWithoutVars = originalValue
.replace(ENV_VAR_PATTERN, "")
.trim()
return trimmedExpanded !== templateWithoutVars
}
}
return true
}
/**
* Resolves a registry URL from a path or URL string.
* Handles special cases like v0 registry URLs that need /json suffix.
*
* @param pathOrUrl - Either a relative path or a full URL
* @returns The resolved registry URL
*/
export function resolveRegistryUrl(pathOrUrl: string) {
if (isUrl(pathOrUrl)) {
// If the url contains /chat/b/, we assume it's the v0 registry.
// We need to add the /json suffix if it's missing.
const url = new URL(pathOrUrl)
if (url.pathname.match(/\/chat\/b\//) && !url.pathname.endsWith("/json")) {
url.pathname = `${url.pathname}/json`
}
return url.toString()
}
return `${REGISTRY_URL}/${pathOrUrl}`
}

View File

@@ -0,0 +1,221 @@
import { BUILTIN_REGISTRIES, FALLBACK_STYLE } from "@/src/registry/constants"
import { createConfig } from "@/src/utils/get-config"
import { describe, expect, it } from "vitest"
import { configWithDefaults } from "./config"
describe("configWithDefaults", () => {
it("should merge built-in registries with user registries", () => {
const userConfig = createConfig({
registries: {
"@custom": "http://example.com/{name}",
},
})
const result = configWithDefaults(userConfig)
expect(result.registries).toEqual({
...BUILTIN_REGISTRIES,
"@custom": "http://example.com/{name}",
})
})
it("should preserve user registries when merging", () => {
const userConfig = createConfig({
registries: {
"@one": "http://one.com/{name}",
"@two": {
url: "http://two.com/{name}",
headers: {
Authorization: "Bearer token",
},
},
},
})
const result = configWithDefaults(userConfig)
expect(result.registries?.["@one"]).toBe("http://one.com/{name}")
expect(result.registries?.["@two"]).toEqual({
url: "http://two.com/{name}",
headers: {
Authorization: "Bearer token",
},
})
expect(result.registries?.["@shadcn"]).toBe(BUILTIN_REGISTRIES["@shadcn"])
})
it("should use FALLBACK_STYLE when style is new-york and tailwind.config is empty", () => {
const config = createConfig({
style: "new-york",
tailwind: {
config: "",
css: "app/globals.css",
baseColor: "slate",
cssVariables: true,
},
})
const result = configWithDefaults(config)
expect(result.style).toBe(FALLBACK_STYLE)
})
it("should keep new-york style when tailwind.config is not empty", () => {
const config = createConfig({
style: "new-york",
tailwind: {
config: "tailwind.config.js",
css: "app/globals.css",
baseColor: "slate",
cssVariables: true,
},
})
const result = configWithDefaults(config)
expect(result.style).toBe("new-york")
})
it("should preserve non-new-york styles regardless of tailwind config", () => {
const config1 = createConfig({
style: "default",
tailwind: {
config: "",
css: "app/globals.css",
baseColor: "slate",
cssVariables: true,
},
})
const result1 = configWithDefaults(config1)
expect(result1.style).toBe("default")
const config2 = createConfig({
style: "miami",
tailwind: {
config: "tailwind.config.js",
css: "app/globals.css",
baseColor: "slate",
cssVariables: true,
},
})
const result2 = configWithDefaults(config2)
expect(result2.style).toBe("miami")
})
it("should use FALLBACK_STYLE when no style is provided", () => {
const config = createConfig({
style: undefined,
})
const result = configWithDefaults(config)
expect(result.style).toBe(FALLBACK_STYLE)
})
it("should deeply merge nested config properties", () => {
const config = createConfig({
tailwind: {
css: "custom/path/globals.css",
prefix: "tw-",
baseColor: "zinc",
cssVariables: false,
},
aliases: {
components: "@/custom-components",
utils: "@/custom-utils",
},
})
const result = configWithDefaults(config)
expect(result.tailwind.css).toBe("custom/path/globals.css")
expect(result.tailwind.prefix).toBe("tw-")
expect(result.tailwind.baseColor).toBe("zinc")
expect(result.tailwind.cssVariables).toBe(false)
expect(result.aliases.components).toBe("@/custom-components")
expect(result.aliases.utils).toBe("@/custom-utils")
})
it("should preserve all user config properties", () => {
const config = createConfig({
style: "default",
tsx: false,
rsc: false,
tailwind: {
config: "custom.config.js",
css: "styles/main.css",
baseColor: "gray",
cssVariables: true,
prefix: "app-",
},
resolvedPaths: {
cwd: "/custom/project",
tailwindConfig: "/custom/project/tailwind.config.js",
tailwindCss: "/custom/project/styles/main.css",
utils: "/custom/project/lib/utils",
components: "/custom/project/components",
ui: "/custom/project/components/ui",
lib: "/custom/project/lib",
hooks: "/custom/project/hooks",
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
ui: "@/components/ui",
lib: "@/lib",
hooks: "@/hooks",
},
})
const result = configWithDefaults(config)
expect(result.tsx).toBe(false)
expect(result.rsc).toBe(false)
expect(result.tailwind.config).toBe("custom.config.js")
expect(result.tailwind.css).toBe("styles/main.css")
expect(result.tailwind.baseColor).toBe("gray")
expect(result.tailwind.prefix).toBe("app-")
expect(result.resolvedPaths.cwd).toBe("/custom/project")
expect(result.resolvedPaths.components).toBe("/custom/project/components")
})
it("should handle empty registries object", () => {
const config = createConfig({
registries: {},
})
const result = configWithDefaults(config)
expect(result.registries).toEqual(BUILTIN_REGISTRIES)
})
it("should override built-in registries if user provides same key", () => {
const config = createConfig({
registries: {
"@shadcn": "http://custom-shadcn.com/{name}",
},
})
const result = configWithDefaults(config)
// User's @shadcn should override the built-in one
expect(result.registries?.["@shadcn"]).toBe(
"http://custom-shadcn.com/{name}"
)
})
it("should validate the final config with configSchema", () => {
const config = createConfig({
style: "default",
registries: {
"@test": "http://test.com/{name}",
},
})
// This should not throw since configSchema.parse is called internally
expect(() => configWithDefaults(config)).not.toThrow()
})
})

View File

@@ -0,0 +1,37 @@
import { BUILTIN_REGISTRIES, FALLBACK_STYLE } from "@/src/registry/constants"
import { configSchema } from "@/src/schema"
import { Config, createConfig, DeepPartial } from "@/src/utils/get-config"
import deepmerge from "deepmerge"
function resolveStyleFromConfig(config: DeepPartial<Config>) {
if (!config.style) {
return FALLBACK_STYLE
}
// Check if we should use new-york-v4 for Tailwind v4.
// We assume that if tailwind.config is empty, we're using Tailwind v4.
if (config.style === "new-york" && config.tailwind?.config === "") {
return FALLBACK_STYLE
}
return config.style
}
export function configWithDefaults(config?: DeepPartial<Config>) {
const baseConfig = createConfig({
style: FALLBACK_STYLE,
registries: BUILTIN_REGISTRIES,
})
if (!config) {
return baseConfig
}
return configSchema.parse(
deepmerge(baseConfig, {
...config,
style: resolveStyleFromConfig(config),
registries: { ...BUILTIN_REGISTRIES, ...config.registries },
})
)
}

View File

@@ -0,0 +1,203 @@
import type { PresetBase } from "@/src/preset/preset"
import { registryConfigSchema } from "@/src/schema"
import { z } from "zod"
export const REGISTRY_URL =
process.env.REGISTRY_URL ?? "https://ui.shadcn.com/r"
export const SHADCN_URL = REGISTRY_URL.replace(/\/r\/?$/, "")
export const FALLBACK_STYLE = "new-york-v4"
export const BASE_COLORS = [
{
name: "neutral",
label: "Neutral",
},
{
name: "zinc",
label: "Zinc",
},
{
name: "stone",
label: "Stone",
},
{
name: "mauve",
label: "Mauve",
},
{
name: "olive",
label: "Olive",
},
{
name: "mist",
label: "Mist",
},
{
name: "taupe",
label: "Taupe",
},
] as const
// Built-in registries that are always available and cannot be overridden
export const BUILTIN_REGISTRIES: z.infer<typeof registryConfigSchema> = {
"@shadcn": `${REGISTRY_URL}/styles/{style}/{name}.json`,
}
export const BUILTIN_MODULES = new Set([
[
// Node.js built-in modules
// From https://github.com/sindresorhus/builtin-modules.
"node:assert",
"assert",
"node:assert/strict",
"assert/strict",
"node:async_hooks",
"async_hooks",
"node:buffer",
"buffer",
"node:child_process",
"child_process",
"node:cluster",
"cluster",
"node:console",
"console",
"node:constants",
"constants",
"node:crypto",
"crypto",
"node:dgram",
"dgram",
"node:diagnostics_channel",
"diagnostics_channel",
"node:dns",
"dns",
"node:dns/promises",
"dns/promises",
"node:domain",
"domain",
"node:events",
"events",
"node:fs",
"fs",
"node:fs/promises",
"fs/promises",
"node:http",
"http",
"node:http2",
"http2",
"node:https",
"https",
"node:inspector",
"inspector",
"node:inspector/promises",
"inspector/promises",
"node:module",
"module",
"node:net",
"net",
"node:os",
"os",
"node:path",
"path",
"node:path/posix",
"path/posix",
"node:path/win32",
"path/win32",
"node:perf_hooks",
"perf_hooks",
"node:process",
"process",
"node:querystring",
"querystring",
"node:quic",
"node:readline",
"readline",
"node:readline/promises",
"readline/promises",
"node:repl",
"repl",
"node:sea",
"node:sqlite",
"node:stream",
"stream",
"node:stream/consumers",
"stream/consumers",
"node:stream/promises",
"stream/promises",
"node:stream/web",
"stream/web",
"node:string_decoder",
"string_decoder",
"node:test",
"node:test/reporters",
"node:timers",
"timers",
"node:timers/promises",
"timers/promises",
"node:tls",
"tls",
"node:trace_events",
"trace_events",
"node:tty",
"tty",
"node:url",
"url",
"node:util",
"util",
"node:util/types",
"util/types",
"node:v8",
"v8",
"node:vm",
"vm",
"node:wasi",
"wasi",
"node:worker_threads",
"worker_threads",
"node:zlib",
"zlib",
// Bun built-in modules.
"bun",
"bun:test",
"bun:sqlite",
"bun:ffi",
"bun:jsc",
"bun:internal",
],
])
type DeprecatedComponent = {
name: string
deprecatedBy: string
message: string
availableIn?: PresetBase[]
}
export const DEPRECATED_COMPONENTS: DeprecatedComponent[] = [
{
name: "toast",
deprecatedBy: "sonner",
message:
"The toast component is only available for Base UI projects. Use the sonner component instead.",
availableIn: ["base"],
},
{
name: "toaster",
deprecatedBy: "sonner",
message:
"The toaster component is deprecated. Use the sonner component instead.",
},
]
export const COMPONENTS_HIDDEN_FROM_SELECTION: {
name: string
hiddenIn: PresetBase[]
}[] = [
{
name: "sonner",
hiddenIn: ["base"],
},
]

View File

@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it } from "vitest"
import {
clearRegistryContext,
getRegistryEnvFromContext,
getRegistryHeadersFromContext,
setRegistryHeaders,
withRegistryContext,
} from "./context"
afterEach(() => {
clearRegistryContext()
})
describe("registry context", () => {
it("isolates headers between concurrent operations", async () => {
const url = "https://example.com/item.json"
const results = await runConcurrently(
["first", "second"],
async (token, waitForOtherOperations) => {
setRegistryHeaders({
[url]: {
Authorization: `Bearer ${token}`,
},
})
await waitForOtherOperations()
return getRegistryHeadersFromContext(url).Authorization
}
)
expect(results).toEqual(["Bearer first", "Bearer second"])
})
it("isolates environment variables between concurrent operations", async () => {
const results = await runConcurrently(
["first", "second"],
async (_token, waitForOtherOperations) => {
await waitForOtherOperations()
return getRegistryEnvFromContext("REGISTRY_TOKEN")
}
)
expect(results).toEqual(["first", "second"])
})
})
async function runConcurrently<T>(
values: string[],
callback: (
value: string,
waitForOtherOperations: () => Promise<void>
) => Promise<T>
) {
let ready = 0
let release!: () => void
const barrier = new Promise<void>((resolve) => {
release = resolve
})
return Promise.all(
values.map((value) =>
withRegistryContext(
() =>
callback(value, async () => {
ready++
if (ready === values.length) {
release()
}
await barrier
}),
{
env: {
REGISTRY_TOKEN: value,
},
}
)
)
)
}

View File

@@ -0,0 +1,65 @@
import { AsyncLocalStorage } from "async_hooks"
interface RegistryContext {
headers: Record<string, Record<string, string>>
env?: NodeJS.ProcessEnv
onGitHubAuthNotice?: (message: string) => void | Promise<void>
}
const registryContext = new AsyncLocalStorage<RegistryContext>()
const fallbackContext: RegistryContext = {
headers: {},
}
export function withRegistryContext<T>(
callback: () => T,
options: {
env?: NodeJS.ProcessEnv
onGitHubAuthNotice?: (message: string) => void | Promise<void>
} = {}
): T {
const parentContext = registryContext.getStore()
return registryContext.run(
{
headers: {},
env: options.env ?? parentContext?.env,
onGitHubAuthNotice:
options.onGitHubAuthNotice ?? parentContext?.onGitHubAuthNotice,
},
callback
)
}
export function setRegistryHeaders(
headers: Record<string, Record<string, string>>
) {
const context = registryContext.getStore() ?? fallbackContext
// Merge new headers with existing ones to preserve headers for nested dependencies
context.headers = { ...context.headers, ...headers }
}
export function getRegistryHeadersFromContext(
url: string
): Record<string, string> {
const context = registryContext.getStore() ?? fallbackContext
return context.headers[url] || {}
}
export function getRegistryEnvFromContext(key: string): string | undefined {
const context = registryContext.getStore()
return context?.env ? context.env[key] : process.env[key]
}
export function getGitHubAuthNoticeFromContext() {
return registryContext.getStore()?.onGitHubAuthNotice
}
export function clearRegistryContext() {
const context = registryContext.getStore() ?? fallbackContext
context.headers = {}
}

View File

@@ -0,0 +1,46 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { expandEnvVars, extractEnvVars } from "./env"
describe("expandEnvVars", () => {
beforeEach(() => {
process.env.TEST_TOKEN = "abc123"
process.env.API_KEY = "secret"
})
afterEach(() => {
delete process.env.TEST_TOKEN
delete process.env.API_KEY
})
it("should expand environment variables", () => {
expect(expandEnvVars("Bearer ${TEST_TOKEN}")).toBe("Bearer abc123")
expect(expandEnvVars("key=${API_KEY}&token=${TEST_TOKEN}")).toBe(
"key=secret&token=abc123"
)
})
it("should replace missing env vars with empty string", () => {
expect(expandEnvVars("Bearer ${MISSING_VAR}")).toBe("Bearer ")
expect(expandEnvVars("${VAR1}:${VAR2}")).toBe(":")
})
it("should handle strings without env vars", () => {
expect(expandEnvVars("no variables here")).toBe("no variables here")
expect(expandEnvVars("https://example.com")).toBe("https://example.com")
})
})
describe("extractEnvVars", () => {
it("should extract environment variable names", () => {
expect(extractEnvVars("Bearer ${TOKEN}")).toEqual(["TOKEN"])
expect(extractEnvVars("${VAR1} and ${VAR2}")).toEqual(["VAR1", "VAR2"])
expect(extractEnvVars("${SAME} and ${SAME}")).toEqual(["SAME", "SAME"])
})
it("should return empty array for no variables", () => {
expect(extractEnvVars("no variables")).toEqual([])
expect(extractEnvVars("")).toEqual([])
})
})

View File

@@ -0,0 +1,20 @@
import { getRegistryEnvFromContext } from "@/src/registry/context"
export function expandEnvVars(value: string) {
return value.replace(
/\${(\w+)}/g,
(_match, key) => getRegistryEnvFromContext(key) || ""
)
}
export function extractEnvVars(value: string) {
const vars: string[] = []
const regex = /\${(\w+)}/g
let match: RegExpExecArray | null
while ((match = regex.exec(value)) !== null) {
vars.push(match[1])
}
return vars
}

View File

@@ -0,0 +1,478 @@
import { SHADCN_URL } from "@/src/registry/constants"
import { z } from "zod"
// Error codes for programmatic error handling
export const RegistryErrorCode = {
// Network errors
NETWORK_ERROR: "NETWORK_ERROR",
NOT_FOUND: "NOT_FOUND",
GONE: "GONE",
UNAUTHORIZED: "UNAUTHORIZED",
FORBIDDEN: "FORBIDDEN",
FETCH_ERROR: "FETCH_ERROR",
// Configuration errors
NOT_CONFIGURED: "NOT_CONFIGURED",
INVALID_CONFIG: "INVALID_CONFIG",
MISSING_ENV_VARS: "MISSING_ENV_VARS",
// File system errors
LOCAL_FILE_ERROR: "LOCAL_FILE_ERROR",
// Parsing errors
PARSE_ERROR: "PARSE_ERROR",
VALIDATION_ERROR: "VALIDATION_ERROR",
// Generic errors
UNKNOWN_ERROR: "UNKNOWN_ERROR",
} as const
export type RegistryErrorCode =
(typeof RegistryErrorCode)[keyof typeof RegistryErrorCode]
export class RegistryError extends Error {
public readonly code: RegistryErrorCode
public readonly statusCode?: number
public readonly context?: Record<string, unknown>
public readonly suggestion?: string
public readonly timestamp: Date
public readonly cause?: unknown
constructor(
message: string,
options: {
code?: RegistryErrorCode
statusCode?: number
cause?: unknown
context?: Record<string, unknown>
suggestion?: string
} = {}
) {
super(message)
this.name = "RegistryError"
this.code = options.code || RegistryErrorCode.UNKNOWN_ERROR
this.statusCode = options.statusCode
this.cause = options.cause
this.context = options.context
this.suggestion = options.suggestion
this.timestamp = new Date()
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor)
}
}
toJSON() {
return {
name: this.name,
message: this.message,
code: this.code,
statusCode: this.statusCode,
context: this.context,
suggestion: this.suggestion,
timestamp: this.timestamp,
stack: this.stack,
}
}
}
export class RegistryNotFoundError extends RegistryError {
constructor(
public readonly url: string,
cause?: unknown
) {
const message = `The item at ${url} was not found. It may not exist at the registry.`
super(message, {
code: RegistryErrorCode.NOT_FOUND,
statusCode: 404,
cause,
context: { url },
suggestion:
"Check if the item name is correct and the registry URL is accessible.",
})
this.name = "RegistryNotFoundError"
}
}
export class RegistryGoneError extends RegistryError {
constructor(
public readonly url: string,
cause?: unknown
) {
const message = `The item at ${url} is no longer available. It may have been removed or expired.`
super(message, {
code: RegistryErrorCode.GONE,
statusCode: 410,
cause,
context: { url },
suggestion:
"This resource was previously available but has been permanently removed. Check if a newer version exists or contact the registry maintainer.",
})
this.name = "RegistryGoneError"
}
}
export class RegistryUnauthorizedError extends RegistryError {
constructor(
public readonly url: string,
cause?: unknown
) {
const message = `You are not authorized to access the item at ${url}. If this is a remote registry, you may need to authenticate.`
super(message, {
code: RegistryErrorCode.UNAUTHORIZED,
statusCode: 401,
cause,
context: { url },
suggestion:
"Check your authentication credentials and environment variables.",
})
this.name = "RegistryUnauthorizedError"
}
}
export class RegistryForbiddenError extends RegistryError {
constructor(
public readonly url: string,
cause?: unknown
) {
const message = `You are not authorized to access the item at ${url}. If this is a remote registry, you may need to authenticate.`
super(message, {
code: RegistryErrorCode.FORBIDDEN,
statusCode: 403,
cause,
context: { url },
suggestion:
"Check your authentication credentials and environment variables.",
})
this.name = "RegistryForbiddenError"
}
}
export class RegistryFetchError extends RegistryError {
constructor(
public readonly url: string,
statusCode?: number,
public readonly responseBody?: string,
cause?: unknown
) {
// Use the error detail from the server if available
const baseMessage = statusCode
? `Failed to fetch from registry (${statusCode}): ${url}`
: `Failed to fetch from registry: ${url}`
const message =
typeof cause === "string" && cause
? `${baseMessage} - ${cause}`
: baseMessage
let suggestion = "Check your network connection and try again."
if (statusCode === 404) {
suggestion =
"The requested resource was not found. Check the URL or item name."
} else if (statusCode === 500) {
suggestion = "The registry server encountered an error. Try again later."
} else if (statusCode && statusCode >= 400 && statusCode < 500) {
suggestion = "There was a client error. Check your request parameters."
}
super(message, {
code: RegistryErrorCode.FETCH_ERROR,
statusCode,
cause,
context: { url, responseBody },
suggestion,
})
this.name = "RegistryFetchError"
}
}
export class RegistryNotConfiguredError extends RegistryError {
constructor(public readonly registryName: string | null) {
const message = registryName
? `Unknown registry "${registryName}". Make sure it is defined under "registries" in your components.json or package.json file:
{
"registries": {
"${registryName}": "[URL_TO_REGISTRY]"
}
}`
: `Unknown registry. Make sure it is defined under "registries" in your components.json or package.json file.`
super(message, {
code: RegistryErrorCode.NOT_CONFIGURED,
context: { registryName },
suggestion:
'Add the registry configuration under "registries" in your components.json or package.json file.',
})
this.name = "RegistryNotConfiguredError"
}
}
export class RegistryLocalFileError extends RegistryError {
constructor(
public readonly filePath: string,
cause?: unknown,
options: {
message?: string
context?: Record<string, unknown>
suggestion?: string
} = {}
) {
super(
options.message ?? `Failed to read local registry file: ${filePath}`,
{
code: RegistryErrorCode.LOCAL_FILE_ERROR,
cause,
context: { filePath, ...options.context },
suggestion:
options.suggestion ??
"Check if the file exists and you have read permissions.",
}
)
this.name = "RegistryLocalFileError"
}
}
export class RegistrySourceFileError extends RegistryError {
constructor(
public readonly filePath: string,
cause?: unknown,
options: {
message?: string
context?: Record<string, unknown>
suggestion?: string
} = {}
) {
super(
options.message ?? `Failed to read registry source file: ${filePath}`,
{
code: RegistryErrorCode.FETCH_ERROR,
cause,
context: { filePath, ...options.context },
suggestion:
options.suggestion ??
"Check if the source file exists and is accessible.",
}
)
this.name = "RegistrySourceFileError"
}
}
export class RegistryParseError extends RegistryError {
public readonly parseError: unknown
constructor(
public readonly item: string,
parseError: unknown,
options: {
subject?: string
context?: Record<string, unknown>
suggestion?: string
} = {}
) {
const subject = options.subject ?? "registry item"
let message = `Failed to parse ${subject}: ${item}`
if (parseError instanceof z.ZodError) {
message = `Failed to parse ${subject}: ${item}\n${parseError.errors
.map((e) => ` - ${e.path.join(".")}: ${e.message}`)
.join("\n")}`
}
super(message, {
code: RegistryErrorCode.PARSE_ERROR,
cause: parseError,
context: { item, ...options.context },
suggestion:
options.suggestion ??
`The registry item may be corrupted or have an invalid format. Please make sure it returns a valid JSON object. See ${SHADCN_URL}/schema/registry-item.json.`,
})
this.parseError = parseError
this.name = "RegistryParseError"
}
}
export class RegistryValidationError extends RegistryError {
constructor(
message: string,
options: {
registryFile?: string
cause?: unknown
context?: Record<string, unknown>
suggestion?: string
} = {}
) {
super(message, {
code: RegistryErrorCode.VALIDATION_ERROR,
cause: options.cause,
context: {
...(options.registryFile ? { registryFile: options.registryFile } : {}),
...options.context,
},
suggestion:
options.suggestion ??
"Update the registry.json file and try running the command again.",
})
this.name = "RegistryValidationError"
}
}
export class RegistryItemNotFoundError extends RegistryError {
constructor(public readonly itemName: string) {
super(`Registry item "${itemName}" was not found.`, {
code: RegistryErrorCode.NOT_FOUND,
statusCode: 404,
context: { itemName },
suggestion:
"Check that the item name exists in the resolved registry catalog.",
})
this.name = "RegistryItemNotFoundError"
}
}
export class RegistryMissingEnvironmentVariablesError extends RegistryError {
constructor(
public readonly registryName: string,
public readonly missingVars: string[]
) {
const message =
`Registry "${registryName}" requires the following environment variables:\n\n` +
missingVars.map((v) => ` • ${v}`).join("\n")
super(message, {
code: RegistryErrorCode.MISSING_ENV_VARS,
context: { registryName, missingVars },
suggestion:
"Set the required environment variables to your .env or .env.local file.",
})
this.name = "RegistryMissingEnvironmentVariablesError"
}
}
export class RegistryInvalidNamespaceError extends RegistryError {
constructor(public readonly name: string) {
const message = `Invalid registry namespace: "${name}". Registry names must start with @ (e.g., @shadcn, @v0).`
super(message, {
code: RegistryErrorCode.VALIDATION_ERROR,
context: { name },
suggestion:
"Use a valid registry name starting with @ or provide a direct URL to the registry.",
})
this.name = "RegistryInvalidNamespaceError"
}
}
export class ConfigMissingError extends RegistryError {
constructor(public readonly cwd: string) {
const message = `No components.json found in ${cwd} or parent directories.`
super(message, {
code: RegistryErrorCode.NOT_CONFIGURED,
context: { cwd },
suggestion:
"Run 'npx shadcn@latest init' to create a components.json file, or check that you're in the correct directory.",
})
this.name = "ConfigMissingError"
}
}
export class ConfigParseError extends RegistryError {
constructor(
public readonly cwd: string,
parseError: unknown,
public readonly configFile:
| "components.json"
| "package.json"
| "config" = "components.json"
) {
const configName =
configFile === "package.json"
? 'package.json "registries"'
: configFile === "config"
? "provided config"
: "components.json"
let message = `Invalid ${configName} configuration in ${cwd}.`
if (parseError instanceof z.ZodError) {
message = `Invalid ${configName} configuration in ${cwd}:\n${parseError.errors
.map((e) => ` - ${e.path.join(".")}: ${e.message}`)
.join("\n")}`
}
super(message, {
code: RegistryErrorCode.INVALID_CONFIG,
cause: parseError,
context: { cwd, configFile },
suggestion:
configFile === "package.json"
? 'Check the "registries" field in your package.json file for invalid configuration.'
: configFile === "config"
? "Pass a valid full project config, or omit resolvedPaths and provide only registry configuration."
: "Check your components.json file for syntax errors or invalid configuration. Run 'npx shadcn@latest init' to regenerate a valid configuration.",
})
this.name = "ConfigParseError"
}
}
export class RegistriesIndexParseError extends RegistryError {
public readonly parseError: unknown
constructor(parseError: unknown) {
let message = "Failed to parse registries index"
if (parseError instanceof z.ZodError) {
const invalidNamespaces = parseError.errors
.filter((e) => e.path.length > 0)
.map((e) => `"${e.path[0]}"`)
.filter((v, i, arr) => arr.indexOf(v) === i) // remove duplicates
if (invalidNamespaces.length > 0) {
message = `Failed to parse registries index. Invalid registry namespace(s): ${invalidNamespaces.join(
", "
)}\n${parseError.errors
.map((e) => ` - ${e.path.join(".")}: ${e.message}`)
.join("\n")}`
} else {
message = `Failed to parse registries index:\n${parseError.errors
.map((e) => ` - ${e.path.join(".")}: ${e.message}`)
.join("\n")}`
}
}
super(message, {
code: RegistryErrorCode.PARSE_ERROR,
cause: parseError,
context: { parseError },
suggestion:
"The registries index may be corrupted or have invalid registry namespace format. Registry names must start with @ (e.g., @shadcn, @example).",
})
this.parseError = parseError
this.name = "RegistriesIndexParseError"
}
}
export class InvalidConfigIconLibraryError extends RegistryError {
constructor(
public readonly iconLibrary: string,
public readonly validOptions: string[]
) {
const message = `Invalid icon library "${iconLibrary}". Valid options are: ${validOptions.join(
", "
)}`
super(message, {
code: RegistryErrorCode.INVALID_CONFIG,
context: { iconLibrary, validOptions },
suggestion: `Update the "iconLibrary" field in your components.json to one of: ${validOptions.join(
", "
)}`,
})
this.name = "InvalidConfigIconLibraryError"
}
}

View File

@@ -0,0 +1,396 @@
import { REGISTRY_URL } from "@/src/registry/constants"
import {
RegistryFetchError,
RegistryForbiddenError,
RegistryGoneError,
RegistryNotFoundError,
RegistryUnauthorizedError,
} from "@/src/registry/errors"
import { http, HttpResponse } from "msw"
import { setupServer } from "msw/node"
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"
import {
clearRegistryContext,
setRegistryHeaders,
withRegistryContext,
} from "./context"
import { clearRegistryCache, fetchRegistry } from "./fetcher"
const server = setupServer(
http.get(`${REGISTRY_URL}/test.json`, () => {
return HttpResponse.json({
name: "test",
type: "registry:ui",
})
}),
http.get(`${REGISTRY_URL}/error.json`, () => {
return HttpResponse.error()
}),
http.get(`${REGISTRY_URL}/not-found.json`, () => {
return new HttpResponse(null, { status: 404 })
}),
http.get(`${REGISTRY_URL}/unauthorized.json`, () => {
return new HttpResponse(null, { status: 401 })
}),
http.get(`${REGISTRY_URL}/forbidden.json`, () => {
return new HttpResponse(null, { status: 403 })
}),
http.get(`${REGISTRY_URL}/gone.json`, () => {
return new HttpResponse(null, { status: 410 })
}),
http.get("https://external.com/component.json", () => {
return HttpResponse.json({
name: "external",
type: "registry:ui",
})
})
)
beforeAll(() => server.listen())
afterEach(() => {
server.resetHandlers()
clearRegistryCache()
})
afterAll(() => server.close())
describe("fetchRegistry", () => {
it("should fetch a single registry item", async () => {
const result = await fetchRegistry(["test.json"])
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
name: "test",
type: "registry:ui",
})
})
it("should fetch multiple registry items in parallel", async () => {
const result = await fetchRegistry(["test.json", "test.json"])
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({ name: "test" })
expect(result[1]).toMatchObject({ name: "test" })
})
it("should fetch from external URLs", async () => {
const result = await fetchRegistry(["https://external.com/component.json"])
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
name: "external",
type: "registry:ui",
})
})
it("should use cache when enabled", async () => {
// First fetch - should hit the server
const result1 = await fetchRegistry(["test.json"], { useCache: true })
expect(result1[0]).toMatchObject({ name: "test" })
// Second fetch - should use cache
const result2 = await fetchRegistry(["test.json"], { useCache: true })
expect(result2[0]).toMatchObject({ name: "test" })
})
it("should separate cached responses by registry headers", async () => {
const url = `${REGISTRY_URL}/authenticated.json`
server.use(
http.get(url, ({ request }) =>
HttpResponse.json({
authorization: request.headers.get("authorization"),
})
)
)
const fetchWithToken = (token: string) =>
withRegistryContext(async () => {
setRegistryHeaders({
[url]: {
Authorization: `Bearer ${token}`,
},
})
const [result] = await fetchRegistry([url], { useCache: true })
return result
})
const [first, second] = await Promise.all([
fetchWithToken("first"),
fetchWithToken("second"),
])
expect(first).toEqual({ authorization: "Bearer first" })
expect(second).toEqual({ authorization: "Bearer second" })
})
it("should not use cache when disabled", async () => {
// Mock the server to return different responses
let callCount = 0
server.use(
http.get(`${REGISTRY_URL}/cache-test.json`, () => {
callCount++
return HttpResponse.json({
name: `test-${callCount}`,
type: "registry:ui",
})
})
)
const result1 = await fetchRegistry(["cache-test.json"], {
useCache: false,
})
expect(result1[0]).toMatchObject({ name: "test-1" })
const result2 = await fetchRegistry(["cache-test.json"], {
useCache: false,
})
expect(result2[0]).toMatchObject({ name: "test-2" })
})
it("should handle 404 errors", async () => {
await expect(fetchRegistry(["not-found.json"])).rejects.toThrow(
RegistryNotFoundError
)
})
it("should handle 401 errors", async () => {
await expect(fetchRegistry(["unauthorized.json"])).rejects.toThrow(
RegistryUnauthorizedError
)
})
it("should handle 403 errors", async () => {
await expect(fetchRegistry(["forbidden.json"])).rejects.toThrow(
RegistryForbiddenError
)
})
it("should handle 410 errors", async () => {
await expect(fetchRegistry(["gone.json"])).rejects.toThrow(
RegistryGoneError
)
})
it("should handle network errors", async () => {
await expect(fetchRegistry(["error.json"])).rejects.toThrow()
})
it("should fetch registry data", async () => {
const paths = ["styles/new-york/button.json"]
const result = await fetchRegistry(paths)
expect(result).toHaveLength(1)
expect(result[0]).toMatchObject({
name: "button",
type: "registry:ui",
dependencies: ["@radix-ui/react-slot"],
})
})
it("should use cache for subsequent requests", async () => {
const paths = ["styles/new-york/button.json"]
let fetchCount = 0
// Clear any existing cache before test
clearRegistryCache()
// Define the handler with counter before making requests
server.use(
http.get(`${REGISTRY_URL}/styles/new-york/button.json`, async () => {
// Add a small delay to simulate network latency
await new Promise((resolve) => setTimeout(resolve, 10))
fetchCount++
return HttpResponse.json({
name: "button",
type: "registry:ui",
dependencies: ["@radix-ui/react-slot"],
files: [
{
path: "registry/new-york/ui/button.tsx",
content: "// button component content",
type: "registry:ui",
},
],
})
})
)
// First request
const result1 = await fetchRegistry(paths)
expect(fetchCount).toBe(1)
expect(result1).toHaveLength(1)
expect(result1[0]).toMatchObject({ name: "button" })
// Second request - should use cache
const result2 = await fetchRegistry(paths)
expect(fetchCount).toBe(1) // Should still be 1
expect(result2).toHaveLength(1)
expect(result2[0]).toMatchObject({ name: "button" })
// Third request - double check cache
const result3 = await fetchRegistry(paths)
expect(fetchCount).toBe(1) // Should still be 1
expect(result3).toHaveLength(1)
expect(result3[0]).toMatchObject({ name: "button" })
})
it("should handle multiple paths", async () => {
const paths = ["styles/new-york/button.json", "styles/new-york/card.json"]
const result = await fetchRegistry(paths)
expect(result).toHaveLength(2)
expect(result[0]).toMatchObject({ name: "button" })
expect(result[1]).toMatchObject({ name: "card" })
})
it("should send specific Accept and User-Agent headers", async () => {
let acceptHeader: string | null = null
let userAgentHeader: string | null = null
server.use(
http.get(`${REGISTRY_URL}/header-test.json`, ({ request }) => {
acceptHeader = request.headers.get("accept")
userAgentHeader = request.headers.get("user-agent")
return HttpResponse.json({
name: "header-test",
type: "registry:ui",
})
})
)
await fetchRegistry(["header-test.json"], { useCache: false })
expect(acceptHeader).toBe(
"application/vnd.shadcn.v1+json, application/json;q=0.9"
)
expect(userAgentHeader).toBe("shadcn")
})
it("should allow per-registry headers to override the default Accept and User-Agent", async () => {
let acceptHeader: string | null = null
let userAgentHeader: string | null = null
server.use(
http.get(`${REGISTRY_URL}/override-test.json`, ({ request }) => {
acceptHeader = request.headers.get("accept")
userAgentHeader = request.headers.get("user-agent")
return HttpResponse.json({
name: "override-test",
type: "registry:ui",
})
})
)
setRegistryHeaders({
[`${REGISTRY_URL}/override-test.json`]: {
Accept: "application/custom+json",
"User-Agent": "custom-client/1.0",
},
})
await fetchRegistry(["override-test.json"], { useCache: false })
expect(acceptHeader).toBe("application/custom+json")
expect(userAgentHeader).toBe("custom-client/1.0")
clearRegistryContext()
})
it("should allow lowercase per-registry headers to override the default Accept and User-Agent", async () => {
let acceptHeader: string | null = null
let userAgentHeader: string | null = null
server.use(
http.get(
`${REGISTRY_URL}/lowercase-override-test.json`,
({ request }) => {
acceptHeader = request.headers.get("accept")
userAgentHeader = request.headers.get("user-agent")
return HttpResponse.json({
name: "lowercase-override-test",
type: "registry:ui",
})
}
)
)
setRegistryHeaders({
[`${REGISTRY_URL}/lowercase-override-test.json`]: {
accept: "application/custom+json",
"user-agent": "custom-client/1.0",
},
})
await fetchRegistry(["lowercase-override-test.json"], { useCache: false })
expect(acceptHeader).toBe("application/custom+json")
expect(userAgentHeader).toBe("custom-client/1.0")
clearRegistryContext()
})
it("should send specific Accept header for direct external URLs", async () => {
let acceptHeader: string | null = null
server.use(
http.get("https://external.com/registry/item.json", ({ request }) => {
acceptHeader = request.headers.get("accept")
return HttpResponse.json({
name: "item",
type: "registry:ui",
})
})
)
await fetchRegistry(["https://external.com/registry/item.json"], {
useCache: false,
})
expect(acceptHeader).toBe(
"application/vnd.shadcn.v1+json, application/json;q=0.9"
)
})
it("should successfully fetch when the server requires the specific Shadcn Accept header (Content Negotiation)", async () => {
server.use(
http.get(`${REGISTRY_URL}/content-negotiation.json`, ({ request }) => {
const accept = request.headers.get("accept")
if (!accept?.includes("application/vnd.shadcn.v1+json")) {
return new HttpResponse(
"<!DOCTYPE html><html><body>Error: Specific header missing</body></html>",
{
status: 200,
headers: { "Content-Type": "text/html" },
}
)
}
return HttpResponse.json({
name: "content-negotiation",
type: "registry:ui",
})
})
)
const [result] = await fetchRegistry(["content-negotiation.json"], {
useCache: false,
})
expect(result).toMatchObject({ name: "content-negotiation" })
})
})
describe("clearRegistryCache", () => {
it("should clear the cache", async () => {
// First fetch - should hit the server
const result1 = await fetchRegistry(["test.json"], { useCache: true })
expect(result1[0]).toMatchObject({ name: "test" })
// Clear cache
clearRegistryCache()
// Mock the server to return different response
server.use(
http.get(`${REGISTRY_URL}/test.json`, () => {
return HttpResponse.json({
name: "test-after-clear",
type: "registry:ui",
})
})
)
// Third fetch - should hit the server again after cache clear
const result3 = await fetchRegistry(["test.json"], { useCache: true })
expect(result3[0]).toMatchObject({ name: "test-after-clear" })
})
})

View File

@@ -0,0 +1,176 @@
import { createHash } from "crypto"
import { promises as fs } from "fs"
import { homedir } from "os"
import path from "path"
import { resolveRegistryUrl } from "@/src/registry/builder"
import { getRegistryHeadersFromContext } from "@/src/registry/context"
import {
RegistryFetchError,
RegistryForbiddenError,
RegistryGoneError,
RegistryLocalFileError,
RegistryNotFoundError,
RegistryParseError,
RegistryUnauthorizedError,
} from "@/src/registry/errors"
import { fetchWithProxy } from "@/src/registry/proxy"
import { registryItemSchema } from "@/src/schema"
import { z } from "zod"
const registryCache = new Map<string, Promise<any>>()
export function clearRegistryCache() {
registryCache.clear()
}
export async function fetchRegistry(
paths: string[],
options: { useCache?: boolean } = {}
) {
options = {
useCache: true,
...options,
}
try {
const results = await Promise.all(
paths.map(async (path) => {
const url = resolveRegistryUrl(path)
const headers = getRegistryHeadersFromContext(url)
const cacheKey = getRegistryCacheKey(url, headers)
// Check cache first if caching is enabled
if (options.useCache && registryCache.has(cacheKey)) {
return registryCache.get(cacheKey)
}
// Store the promise in the cache before awaiting if caching is enabled.
const fetchPromise = (async () => {
const requestHeaders = new Headers({
Accept: "application/vnd.shadcn.v1+json, application/json;q=0.9",
"User-Agent": "shadcn",
})
for (const [key, value] of Object.entries(headers)) {
requestHeaders.set(key, value)
}
const response = await fetchWithProxy(url, {
headers: requestHeaders,
})
if (!response.ok) {
let messageFromServer = undefined
if (
response.headers.get("content-type")?.includes("application/json")
) {
const json = await response.json()
const parsed = z
.object({
// RFC 7807.
detail: z.string().optional(),
title: z.string().optional(),
// Standard error response.
message: z.string().optional(),
error: z.string().optional(),
})
.safeParse(json)
if (parsed.success) {
// Prefer RFC 7807 detail field, then message field.
messageFromServer = parsed.data.detail || parsed.data.message
if (parsed.data.error) {
messageFromServer = `[${parsed.data.error}] ${messageFromServer}`
}
}
}
if (response.status === 401) {
throw new RegistryUnauthorizedError(url, messageFromServer)
}
if (response.status === 404) {
throw new RegistryNotFoundError(url, messageFromServer)
}
if (response.status === 410) {
throw new RegistryGoneError(url, messageFromServer)
}
if (response.status === 403) {
throw new RegistryForbiddenError(url, messageFromServer)
}
throw new RegistryFetchError(
url,
response.status,
messageFromServer
)
}
return response.json()
})()
if (options.useCache) {
registryCache.set(cacheKey, fetchPromise)
}
return fetchPromise
})
)
return results
} catch (error) {
throw error
}
}
function getRegistryCacheKey(
url: string,
headers: Record<string, string>
): string {
const normalizedHeaders = Object.entries(headers)
.map(([key, value]) => [key.toLowerCase(), value] as const)
.sort(([a], [b]) => a.localeCompare(b))
const headersHash = createHash("sha256")
.update(JSON.stringify(normalizedHeaders))
.digest("hex")
return `${url}:${headersHash}`
}
export async function fetchRegistryLocal(filePath: string) {
try {
// Handle tilde expansion for home directory
let expandedPath = filePath
if (filePath.startsWith("~/")) {
expandedPath = path.join(homedir(), filePath.slice(2))
}
const resolvedPath = path.resolve(expandedPath)
const content = await fs.readFile(resolvedPath, "utf8")
const parsed = JSON.parse(content)
try {
return registryItemSchema.parse(parsed)
} catch (error) {
throw new RegistryParseError(filePath, error)
}
} catch (error) {
// Check if this is a file not found error
if (
error instanceof Error &&
(error.message.includes("ENOENT") ||
error.message.includes("no such file"))
) {
throw new RegistryLocalFileError(filePath, error)
}
// Re-throw parse errors as-is
if (error instanceof RegistryParseError) {
throw error
}
// For other errors (like JSON parse errors), throw as local file error
throw new RegistryLocalFileError(filePath, error)
}
}

View File

@@ -0,0 +1,94 @@
import { getGitHubAuthNoticeFromContext } from "@/src/registry/context"
import {
getEnvGitHubToken,
type GitHubAuthMode,
} from "@/src/registry/github-cli"
import type { GitHubSource } from "@/src/registry/github-ref"
import { logAboveSpinner } from "@/src/utils/spinner"
import { gray, green } from "kleur/colors"
export type GitHubSourceAuthState = {
// Single-flight mode selection shared by ref resolution and content reads.
decision?: Promise<GitHubAuthMode>
// Set once the anonymous root registry.json succeeded. A locked source
// never sends credentials, so a missing child file stays anonymous.
anonymousLock: boolean
// The pre-auth failure, preserved so an authenticated 404 keeps GitHub's
// private-versus-missing ambiguity.
originalError?: unknown
}
// Auth state is anchored on the command-local sourceCache object, which the
// resolver already creates once and threads through concurrent item fetches
// and recursive dependency resolution.
const coordinators = new WeakMap<object, Map<string, GitHubSourceAuthState>>()
// A command can make several top-level registry calls (preflight, catalog,
// tree resolution), each with its own sourceCache. The notice dedupes
// process-wide per credential mode so it prints once, not once per phase.
const notifiedModes = new Set<GitHubAuthMode>()
export function resetGitHubAuthNotices() {
notifiedModes.clear()
}
export function getGitHubAuthState(anchor: object, source: GitHubSource) {
let sources = coordinators.get(anchor)
if (!sources) {
sources = new Map()
coordinators.set(anchor, sources)
}
const key = normalizeGitHubSourceKey(source)
let state = sources.get(key)
if (!state) {
state = { anonymousLock: false }
sources.set(key, state)
}
return state
}
export function selectGitHubAuthMode(
state: GitHubSourceAuthState,
source: GitHubSource,
originalError: unknown
) {
if (!state.decision) {
state.originalError = originalError
state.decision = decideAndNotify().catch((error) => {
state.decision = undefined
throw error
})
}
return state.decision
}
async function decideAndNotify() {
const mode: GitHubAuthMode = getEnvGitHubToken() ? "token" : "gh"
if (notifiedModes.has(mode)) {
return mode
}
// The notice is awaited so it lands before the first authenticated request.
const notice = `Using ${mode === "token" ? "GH_TOKEN" : "gh"} credentials.`
const onNotice = getGitHubAuthNoticeFromContext()
if (onNotice) {
await onNotice(notice)
} else {
// Match ora's persisted-line style so the notice aligns with the
// surrounding spinner output.
logAboveSpinner(`${green("✔")} ${gray(notice)}`)
}
notifiedModes.add(mode)
return mode
}
function normalizeGitHubSourceKey(source: GitHubSource) {
return `${source.owner.toLowerCase()}/${source.repo.toLowerCase()}#${
source.ref ?? "HEAD"
}`
}

View File

@@ -0,0 +1,90 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { afterEach, describe, expect, it, vi } from "vitest"
import { fetchGitHubFileViaGh, GitHubTransportError } from "./github-cli"
const ADDRESS = { owner: "acme", repo: "ui" }
const SHA = "1111111111111111111111111111111111111111"
function createFakeGh(script: string) {
const dir = mkdtempSync(path.join(tmpdir(), "shadcn-fake-gh-"))
const binPath = path.join(dir, "gh")
writeFileSync(binPath, `#!/bin/sh\n${script}\n`)
chmodSync(binPath, 0o755)
return dir
}
describe("gh executor with a real subprocess", () => {
const tempDirs: string[] = []
afterEach(() => {
vi.unstubAllEnvs()
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
it("returns fake-gh stdout as file content", async () => {
const dir = createFakeGh(`printf 'export function Button() {}'`)
tempDirs.push(dir)
vi.stubEnv("PATH", dir)
await expect(
fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx")
).resolves.toBe("export function Button() {}")
})
it("classifies a real nonzero exit with an HTTP status from stderr", async () => {
const secret = "ghp_secret_abcdef123456"
const dir = createFakeGh(
`echo "gh: ${secret} Not Found (HTTP 404)" >&2\nexit 1`
)
tempDirs.push(dir)
vi.stubEnv("PATH", dir)
const error = await fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx").catch(
(caught) => caught
)
expect(error).toBeInstanceOf(GitHubTransportError)
expect(error.kind).toBe("http")
expect(error.statusCode).toBe(404)
// Real execa errors embed stderr in their message; the sanitized
// classification must not.
const rendered = JSON.stringify({
message: error.message,
stack: error.stack,
...error,
})
expect(rendered).not.toContain(secret)
})
it("never leaks partial stdout from a failed subprocess", async () => {
const dir = createFakeGh(
`printf 'partial private source content'\necho "gh: boom" >&2\nexit 1`
)
tempDirs.push(dir)
vi.stubEnv("PATH", dir)
const error = await fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx").catch(
(caught) => caught
)
expect(error).toBeInstanceOf(GitHubTransportError)
expect(
JSON.stringify({ message: error.message, stack: error.stack, ...error })
).not.toContain("partial private source")
})
it("classifies a missing gh binary from a real spawn failure", async () => {
const dir = mkdtempSync(path.join(tmpdir(), "shadcn-empty-path-"))
tempDirs.push(dir)
vi.stubEnv("PATH", dir)
await expect(
fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx")
).rejects.toMatchObject({ kind: "enoent" })
})
})

View File

@@ -0,0 +1,541 @@
import { execa } from "execa"
import { http, HttpResponse } from "msw"
import { setupServer } from "msw/node"
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest"
import { withRegistryContext } from "./context"
import {
encodeGitHubPath,
fetchGitHubFileViaGh,
fetchGitHubFileViaRest,
getEnvGitHubToken,
GitHubTransportError,
readGitHubResponseTextWithLimit,
resolveGitHubRefViaAuth,
} from "./github-cli"
vi.mock("execa", () => ({
execa: vi.fn(),
}))
const server = setupServer()
const ADDRESS = { owner: "acme", repo: "ui" }
const SHA = "1111111111111111111111111111111111111111"
const BRANCH_SHA = "2222222222222222222222222222222222222222"
const TAG_SHA = "3333333333333333333333333333333333333333"
describe("github-cli", () => {
beforeAll(() => {
server.listen({ onUnhandledRequest: "error" })
})
beforeEach(() => {
vi.stubEnv("GH_TOKEN", "")
vi.stubEnv("GITHUB_TOKEN", "")
vi.mocked(execa).mockReset()
})
afterEach(() => {
server.resetHandlers()
vi.unstubAllEnvs()
})
afterAll(() => {
server.close()
})
describe("getEnvGitHubToken", () => {
it("returns null when no token is configured", () => {
expect(getEnvGitHubToken()).toBeNull()
})
it("prefers GH_TOKEN over GITHUB_TOKEN", () => {
vi.stubEnv("GH_TOKEN", "gh-token")
vi.stubEnv("GITHUB_TOKEN", "github-token")
expect(getEnvGitHubToken()).toBe("gh-token")
})
it("trims token values", () => {
vi.stubEnv("GH_TOKEN", " padded-token ")
expect(getEnvGitHubToken()).toBe("padded-token")
})
it("falls back to a valid second variable when the first is unsafe", () => {
vi.stubEnv("GH_TOKEN", "bad token with spaces")
vi.stubEnv("GITHUB_TOKEN", "good-token")
expect(getEnvGitHubToken()).toBe("good-token")
})
it("rejects tokens with control characters", () => {
vi.stubEnv("GH_TOKEN", "bad\ntoken")
expect(getEnvGitHubToken()).toBeNull()
})
it("reads through the scoped registry context env", () => {
vi.stubEnv("GH_TOKEN", "process-token")
const token = withRegistryContext(() => getEnvGitHubToken(), {
env: { GH_TOKEN: "context-token" },
})
expect(token).toBe("context-token")
})
it("does not fall back to process.env when a context env is set", () => {
vi.stubEnv("GH_TOKEN", "process-token")
const token = withRegistryContext(() => getEnvGitHubToken(), {
env: {},
})
expect(token).toBeNull()
})
})
describe("fetchGitHubFileViaGh", () => {
it("invokes gh with pinned hostname, fixed headers, and a hermetic env", async () => {
vi.mocked(execa).mockResolvedValueOnce({ stdout: "file content" } as any)
await expect(
fetchGitHubFileViaGh(ADDRESS, SHA, "components/ui/button.tsx")
).resolves.toBe("file content")
expect(vi.mocked(execa)).toHaveBeenCalledWith(
"gh",
[
"api",
"--hostname",
"github.com",
`repos/acme/ui/contents/components/ui/button.tsx?ref=${SHA}`,
"-H",
"Accept: application/vnd.github.raw+json",
"-H",
"X-GitHub-Api-Version: 2022-11-28",
],
expect.objectContaining({
extendEnv: false,
timeout: 15_000,
stripFinalNewline: false,
})
)
const env = (vi.mocked(execa).mock.calls[0] as any[])[2]
.env as NodeJS.ProcessEnv
expect(env.GH_HOST).toBe("github.com")
expect(env.GH_PROMPT_DISABLED).toBe("1")
expect(env.GH_NO_UPDATE_NOTIFIER).toBe("1")
expect(env.NO_COLOR).toBe("1")
expect(env).not.toHaveProperty("GH_TOKEN")
expect(env).not.toHaveProperty("GITHUB_TOKEN")
expect(env).not.toHaveProperty("GH_ENTERPRISE_TOKEN")
expect(env).not.toHaveProperty("GITHUB_ENTERPRISE_TOKEN")
expect(env).not.toHaveProperty("GH_DEBUG")
expect(env).not.toHaveProperty("DEBUG")
expect(env).not.toHaveProperty("GH_FORCE_TTY")
expect(env).not.toHaveProperty("GH_TELEMETRY")
})
it("scrubs inherited gh env vars even when set in the parent", async () => {
vi.stubEnv("GH_HOST", "github.enterprise.example")
vi.stubEnv("GH_TOKEN", "inherited-token")
vi.stubEnv("GH_DEBUG", "api")
vi.mocked(execa).mockResolvedValueOnce({ stdout: "ok" } as any)
await fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx")
const env = (vi.mocked(execa).mock.calls[0] as any[])[2]
.env as NodeJS.ProcessEnv
expect(env.GH_HOST).toBe("github.com")
expect(env).not.toHaveProperty("GH_TOKEN")
expect(env).not.toHaveProperty("GH_DEBUG")
})
it("encodes path segments without encoding separators", async () => {
vi.mocked(execa).mockResolvedValueOnce({ stdout: "ok" } as any)
await fetchGitHubFileViaGh(ADDRESS, SHA, "dir with space/a?b.tsx")
expect(vi.mocked(execa).mock.calls[0]![1]).toContain(
`repos/acme/ui/contents/dir%20with%20space/a%3Fb.tsx?ref=${SHA}`
)
})
it("classifies a missing gh binary", async () => {
vi.mocked(execa).mockRejectedValueOnce(
Object.assign(new Error("spawn gh ENOENT"), { code: "ENOENT" })
)
await expect(
fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx")
).rejects.toMatchObject({ kind: "enoent" })
})
it("classifies a timeout", async () => {
vi.mocked(execa).mockRejectedValueOnce(
Object.assign(new Error("timed out"), { timedOut: true })
)
await expect(
fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx")
).rejects.toMatchObject({ kind: "timeout" })
})
it("classifies an unauthenticated gh", async () => {
vi.mocked(execa).mockRejectedValueOnce(
Object.assign(new Error("exit 4"), {
stderr: "To get started with GitHub CLI, please run: gh auth login",
})
)
await expect(
fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx")
).rejects.toMatchObject({ kind: "unauthenticated" })
})
it("parses a validated HTTP status from gh stderr", async () => {
vi.mocked(execa).mockRejectedValueOnce(
Object.assign(new Error("exit 1"), {
stderr: "gh: Not Found (HTTP 404)",
})
)
await expect(
fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx")
).rejects.toMatchObject({ kind: "http", statusCode: 404 })
})
it("treats unknown stderr as a generic network failure", async () => {
vi.mocked(execa).mockRejectedValueOnce(
Object.assign(new Error("exit 1"), { stderr: "something odd" })
)
await expect(
fetchGitHubFileViaGh(ADDRESS, SHA, "button.tsx")
).rejects.toMatchObject({ kind: "network" })
})
it("never leaks subprocess output into the sanitized failure", async () => {
const secret = "ghp_secret_value_1234567890"
vi.mocked(execa).mockRejectedValueOnce(
Object.assign(
new Error(`Command failed: gh api ...\n${secret}\nprivate source`),
{
stderr: `gh: boom ${secret} (HTTP 500)`,
stdout: `partial private content ${secret}`,
}
)
)
const error = await fetchGitHubFileViaGh(
ADDRESS,
SHA,
"button.tsx"
).catch((caught) => caught)
expect(error).toBeInstanceOf(GitHubTransportError)
expect(error.statusCode).toBe(500)
const rendered = JSON.stringify({
message: error.message,
stack: error.stack,
...error,
})
expect(rendered).not.toContain(secret)
expect(rendered).not.toContain("private")
})
it("bounds concurrent gh processes to eight", async () => {
let active = 0
let maxActive = 0
vi.mocked(execa).mockImplementation((() => {
active += 1
maxActive = Math.max(maxActive, active)
return new Promise((resolve) =>
setTimeout(() => {
active -= 1
resolve({ stdout: "ok" })
}, 5)
)
}) as any)
await Promise.all(
Array.from({ length: 20 }, (_, index) =>
fetchGitHubFileViaGh(ADDRESS, SHA, `file-${index}.tsx`)
)
)
expect(maxActive).toBeLessThanOrEqual(8)
expect(maxActive).toBeGreaterThan(1)
})
})
describe("fetchGitHubFileViaRest", () => {
it("sends the token to api.github.com with the raw media type", async () => {
let capturedHeaders: Headers | undefined
server.use(
http.get(
"https://api.github.com/repos/acme/ui/contents/button.tsx",
({ request }) => {
capturedHeaders = request.headers
return HttpResponse.text("file content")
}
)
)
await expect(
fetchGitHubFileViaRest(ADDRESS, SHA, "button.tsx", "test-token")
).resolves.toBe("file content")
expect(capturedHeaders?.get("authorization")).toBe("Bearer test-token")
expect(capturedHeaders?.get("accept")).toBe(
"application/vnd.github.raw+json"
)
expect(capturedHeaders?.get("x-github-api-version")).toBe("2022-11-28")
})
it("classifies HTTP failures without leaking the token", async () => {
server.use(
http.get(
"https://api.github.com/repos/acme/ui/contents/button.tsx",
() => new HttpResponse(null, { status: 401 })
)
)
const error = await fetchGitHubFileViaRest(
ADDRESS,
SHA,
"button.tsx",
"super-secret-token"
).catch((caught) => caught)
expect(error).toBeInstanceOf(GitHubTransportError)
expect(error.statusCode).toBe(401)
expect(
JSON.stringify({ message: error.message, ...error })
).not.toContain("super-secret-token")
})
it("rejects oversized files by content length", async () => {
server.use(
http.get(
"https://api.github.com/repos/acme/ui/contents/button.tsx",
() =>
new HttpResponse("tiny", {
headers: { "Content-Length": String(100 * 1024 * 1024) },
})
)
)
await expect(
fetchGitHubFileViaRest(ADDRESS, SHA, "button.tsx", "test-token")
).rejects.toMatchObject({ kind: "oversize" })
})
})
describe("readGitHubResponseTextWithLimit", () => {
it("reads a body within the limit", async () => {
await expect(
readGitHubResponseTextWithLimit(new Response("hello"), 10)
).resolves.toBe("hello")
})
it("rejects a streamed body that crosses the limit", async () => {
await expect(
readGitHubResponseTextWithLimit(
new Response("this is longer than the limit"),
10
)
).rejects.toMatchObject({ kind: "oversize" })
})
})
describe("resolveGitHubRefViaAuth (token mode)", () => {
beforeEach(() => {
vi.stubEnv("GH_TOKEN", "test-token")
})
it("prefers the branch when a branch and tag share a name", async () => {
server.use(
http.get(
"https://api.github.com/repos/acme/ui/commits/heads/release",
() => HttpResponse.json({ sha: BRANCH_SHA })
),
http.get(
"https://api.github.com/repos/acme/ui/commits/tags/release",
() => HttpResponse.json({ sha: TAG_SHA })
)
)
await expect(
resolveGitHubRefViaAuth(ADDRESS, "release", "token")
).resolves.toBe(BRANCH_SHA)
})
it("falls back to the tag only on a branch 404", async () => {
server.use(
http.get(
"https://api.github.com/repos/acme/ui/commits/heads/v1.0.0",
() => new HttpResponse(null, { status: 404 })
),
http.get(
"https://api.github.com/repos/acme/ui/commits/tags/v1.0.0",
() => HttpResponse.json({ sha: TAG_SHA })
)
)
await expect(
resolveGitHubRefViaAuth(ADDRESS, "v1.0.0", "token")
).resolves.toBe(TAG_SHA)
})
it("treats a non-404 branch failure as terminal", async () => {
let tagRequests = 0
server.use(
http.get(
"https://api.github.com/repos/acme/ui/commits/heads/main",
() => new HttpResponse(null, { status: 500 })
),
http.get(
"https://api.github.com/repos/acme/ui/commits/tags/main",
() => {
tagRequests += 1
return HttpResponse.json({ sha: TAG_SHA })
}
)
)
await expect(
resolveGitHubRefViaAuth(ADDRESS, "main", "token")
).rejects.toMatchObject({ kind: "http", statusCode: 500 })
expect(tagRequests).toBe(0)
})
it("resolves HEAD through the commits endpoint", async () => {
server.use(
http.get("https://api.github.com/repos/acme/ui/commits/HEAD", () =>
HttpResponse.json({ sha: SHA })
)
)
await expect(
resolveGitHubRefViaAuth(ADDRESS, "HEAD", "token")
).resolves.toBe(SHA)
})
it("resolves fully qualified branch and tag refs directly", async () => {
server.use(
http.get(
"https://api.github.com/repos/acme/ui/commits/heads/main",
() => HttpResponse.json({ sha: BRANCH_SHA })
),
http.get(
"https://api.github.com/repos/acme/ui/commits/tags/v1.0.0",
() => HttpResponse.json({ sha: TAG_SHA })
)
)
await expect(
resolveGitHubRefViaAuth(ADDRESS, "refs/heads/main", "token")
).resolves.toBe(BRANCH_SHA)
await expect(
resolveGitHubRefViaAuth(ADDRESS, "refs/tags/v1.0.0", "token")
).resolves.toBe(TAG_SHA)
})
it("resolves other qualified refs through the git refs API with tag peeling", async () => {
const tagObjectSha = "4444444444444444444444444444444444444444"
server.use(
http.get(
"https://api.github.com/repos/acme/ui/git/ref/pull/1/head",
() =>
HttpResponse.json({
object: { type: "tag", sha: tagObjectSha },
})
),
http.get(
`https://api.github.com/repos/acme/ui/git/tags/${tagObjectSha}`,
() =>
HttpResponse.json({
object: { type: "commit", sha: SHA },
})
)
)
await expect(
resolveGitHubRefViaAuth(ADDRESS, "refs/pull/1/head", "token")
).resolves.toBe(SHA)
})
it("encodes unsafe ref characters", async () => {
let capturedUrl: string | undefined
server.use(
http.get("https://api.github.com/*", ({ request }) => {
capturedUrl = request.url
return HttpResponse.json({ sha: SHA })
})
)
await resolveGitHubRefViaAuth(ADDRESS, "a?b&c", "token")
expect(capturedUrl).toContain("/commits/heads/a%3Fb%26c")
})
it("rejects malformed SHAs from the API", async () => {
server.use(
http.get(
"https://api.github.com/repos/acme/ui/commits/heads/main",
() => HttpResponse.json({ sha: "not-a-sha" })
)
)
await expect(
resolveGitHubRefViaAuth(ADDRESS, "refs/heads/main", "token")
).rejects.toMatchObject({ kind: "invalid-response" })
})
})
describe("resolveGitHubRefViaAuth (gh mode)", () => {
it("resolves through gh api and validates the SHA", async () => {
vi.mocked(execa).mockResolvedValueOnce({
stdout: JSON.stringify({ sha: SHA }),
} as any)
await expect(
resolveGitHubRefViaAuth(ADDRESS, "refs/heads/main", "gh")
).resolves.toBe(SHA)
expect(vi.mocked(execa).mock.calls[0]![1]).toContain(
"repos/acme/ui/commits/heads/main"
)
expect(vi.mocked(execa).mock.calls[0]![1]).toContain("--hostname")
})
it("rejects unparseable gh output", async () => {
vi.mocked(execa).mockResolvedValueOnce({
stdout: "not json",
} as any)
await expect(
resolveGitHubRefViaAuth(ADDRESS, "refs/heads/main", "gh")
).rejects.toMatchObject({ kind: "invalid-response" })
})
})
describe("encodeGitHubPath", () => {
it("encodes segments and preserves separators", () => {
expect(encodeGitHubPath("a b/c?d/e%f")).toBe("a%20b/c%3Fd/e%25f")
})
})
})

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