snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
3
packages/shadcn/.gitignore
vendored
Normal file
3
packages/shadcn/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
components
|
||||
dist
|
||||
.turbo
|
||||
1
packages/shadcn/.prettierignore
Normal file
1
packages/shadcn/.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
test
|
||||
1025
packages/shadcn/CHANGELOG.md
Normal file
1025
packages/shadcn/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
61
packages/shadcn/README.md
Normal file
61
packages/shadcn/README.md
Normal 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).
|
||||
135
packages/shadcn/package.json
Normal file
135
packages/shadcn/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
470
packages/shadcn/src/colors.ts
Normal file
470
packages/shadcn/src/colors.ts
Normal 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()
|
||||
}
|
||||
739
packages/shadcn/src/commands/add.test.ts
Normal file
739
packages/shadcn/src/commands/add.test.ts
Normal 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")
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
430
packages/shadcn/src/commands/add.ts
Normal file
430
packages/shadcn/src/commands/add.ts
Normal 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)
|
||||
}
|
||||
186
packages/shadcn/src/commands/apply.test.ts
Normal file
186
packages/shadcn/src/commands/apply.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
601
packages/shadcn/src/commands/apply.ts
Normal file
601
packages/shadcn/src/commands/apply.ts
Normal 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)}`
|
||||
}
|
||||
142
packages/shadcn/src/commands/build.test.ts
Normal file
142
packages/shadcn/src/commands/build.test.ts
Normal 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
|
||||
}
|
||||
103
packages/shadcn/src/commands/build.ts
Normal file
103
packages/shadcn/src/commands/build.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
255
packages/shadcn/src/commands/diff.ts
Normal file
255
packages/shadcn/src/commands/diff.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
20
packages/shadcn/src/commands/docs.test.ts
Normal file
20
packages/shadcn/src/commands/docs.test.ts
Normal 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"
|
||||
)
|
||||
})
|
||||
})
|
||||
127
packages/shadcn/src/commands/docs.ts
Normal file
127
packages/shadcn/src/commands/docs.ts
Normal 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,
|
||||
])
|
||||
)
|
||||
}
|
||||
262
packages/shadcn/src/commands/eject.test.ts
Normal file
262
packages/shadcn/src/commands/eject.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
248
packages/shadcn/src/commands/eject.ts
Normal file
248
packages/shadcn/src/commands/eject.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
299
packages/shadcn/src/commands/info.ts
Normal file
299
packages/shadcn/src/commands/info.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
298
packages/shadcn/src/commands/init.test.ts
Normal file
298
packages/shadcn/src/commands/init.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
1049
packages/shadcn/src/commands/init.ts
Normal file
1049
packages/shadcn/src/commands/init.ts
Normal file
File diff suppressed because it is too large
Load Diff
265
packages/shadcn/src/commands/mcp.ts
Normal file
265
packages/shadcn/src/commands/mcp.ts
Normal 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
|
||||
}
|
||||
151
packages/shadcn/src/commands/migrate.ts
Normal file
151
packages/shadcn/src/commands/migrate.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
492
packages/shadcn/src/commands/preset.test.ts
Normal file
492
packages/shadcn/src/commands/preset.test.ts
Normal 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}`)
|
||||
})
|
||||
}
|
||||
231
packages/shadcn/src/commands/preset.ts
Normal file
231
packages/shadcn/src/commands/preset.ts
Normal 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)
|
||||
}
|
||||
214
packages/shadcn/src/commands/registry/add.test.ts
Normal file
214
packages/shadcn/src/commands/registry/add.test.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
254
packages/shadcn/src/commands/registry/add.ts
Normal file
254
packages/shadcn/src/commands/registry/add.ts
Normal 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[]
|
||||
}
|
||||
224
packages/shadcn/src/commands/registry/build.ts
Normal file
224
packages/shadcn/src/commands/registry/build.ts
Normal 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
|
||||
}
|
||||
9
packages/shadcn/src/commands/registry/index.ts
Normal file
9
packages/shadcn/src/commands/registry/index.ts
Normal 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)
|
||||
22
packages/shadcn/src/commands/registry/mcp.ts
Normal file
22
packages/shadcn/src/commands/registry/mcp.ts
Normal 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()
|
||||
})
|
||||
190
packages/shadcn/src/commands/registry/validate.test.ts
Normal file
190
packages/shadcn/src/commands/registry/validate.test.ts
Normal 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
|
||||
}
|
||||
211
packages/shadcn/src/commands/registry/validate.ts
Normal file
211
packages/shadcn/src/commands/registry/validate.ts
Normal 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
|
||||
}
|
||||
}
|
||||
333
packages/shadcn/src/commands/search.test.ts
Normal file
333
packages/shadcn/src/commands/search.test.ts
Normal 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}`)
|
||||
})
|
||||
}
|
||||
234
packages/shadcn/src/commands/search.ts
Normal file
234
packages/shadcn/src/commands/search.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
78
packages/shadcn/src/commands/view.ts
Normal file
78
packages/shadcn/src/commands/view.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
2
packages/shadcn/src/icons/index.ts
Normal file
2
packages/shadcn/src/icons/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./libraries"
|
||||
export * from "./templates"
|
||||
49
packages/shadcn/src/icons/libraries.ts
Normal file
49
packages/shadcn/src/icons/libraries.ts
Normal 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
|
||||
128
packages/shadcn/src/icons/templates.ts
Normal file
128
packages/shadcn/src/icons/templates.ts
Normal 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
|
||||
}
|
||||
54
packages/shadcn/src/index.ts
Normal file
54
packages/shadcn/src/index.ts
Normal 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"
|
||||
598
packages/shadcn/src/mcp/index.ts
Normal file
598
packages/shadcn/src/mcp/index.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
312
packages/shadcn/src/mcp/utils.test.ts
Normal file
312
packages/shadcn/src/mcp/utils.test.ts
Normal 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":')
|
||||
})
|
||||
})
|
||||
168
packages/shadcn/src/mcp/utils.ts
Normal file
168
packages/shadcn/src/mcp/utils.ts
Normal 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")
|
||||
}
|
||||
321
packages/shadcn/src/migrations/cn/bindings.ts
Normal file
321
packages/shadcn/src/migrations/cn/bindings.ts
Normal 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(", ")
|
||||
}
|
||||
51
packages/shadcn/src/migrations/cn/cnfast.ts
Normal file
51
packages/shadcn/src/migrations/cn/cnfast.ts
Normal 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
|
||||
}
|
||||
108
packages/shadcn/src/migrations/cn/collect-bindings.ts
Normal file
108
packages/shadcn/src/migrations/cn/collect-bindings.ts
Normal 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"
|
||||
}
|
||||
80
packages/shadcn/src/migrations/cn/emit.ts
Normal file
80
packages/shadcn/src/migrations/cn/emit.ts
Normal 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
|
||||
)
|
||||
}
|
||||
126
packages/shadcn/src/migrations/cn/files.ts
Normal file
126
packages/shadcn/src/migrations/cn/files.ts
Normal 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
|
||||
}
|
||||
}
|
||||
187
packages/shadcn/src/migrations/cn/index.ts
Normal file
187
packages/shadcn/src/migrations/cn/index.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
101
packages/shadcn/src/migrations/cn/issues.ts
Normal file
101
packages/shadcn/src/migrations/cn/issues.ts
Normal 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"
|
||||
}
|
||||
127
packages/shadcn/src/migrations/cn/references.ts
Normal file
127
packages/shadcn/src/migrations/cn/references.ts
Normal 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"
|
||||
}
|
||||
358
packages/shadcn/src/migrations/cn/specifiers.ts
Normal file
358
packages/shadcn/src/migrations/cn/specifiers.ts
Normal 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"
|
||||
}
|
||||
170
packages/shadcn/src/migrations/cn/transform.ts
Normal file
170
packages/shadcn/src/migrations/cn/transform.ts
Normal 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
|
||||
}
|
||||
61
packages/shadcn/src/migrations/cn/types.ts
Normal file
61
packages/shadcn/src/migrations/cn/types.ts
Normal 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
|
||||
}
|
||||
178
packages/shadcn/src/migrations/migrate-base-color.test.ts
Normal file
178
packages/shadcn/src/migrations/migrate-base-color.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
318
packages/shadcn/src/migrations/migrate-base-color.ts
Normal file
318
packages/shadcn/src/migrations/migrate-base-color.ts
Normal 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)}.`
|
||||
)
|
||||
}
|
||||
986
packages/shadcn/src/migrations/migrate-cn.test.ts
Normal file
986
packages/shadcn/src/migrations/migrate-cn.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
2
packages/shadcn/src/migrations/migrate-cn.ts
Normal file
2
packages/shadcn/src/migrations/migrate-cn.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { migrateCn, transformCnSource } from "./cn"
|
||||
export type { CnMigrationIssue, CnSourceTransformResult } from "./cn"
|
||||
542
packages/shadcn/src/migrations/migrate-icons.test.ts
Normal file
542
packages/shadcn/src/migrations/migrate-icons.test.ts
Normal 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>
|
||||
)
|
||||
}"
|
||||
`)
|
||||
})
|
||||
})
|
||||
779
packages/shadcn/src/migrations/migrate-icons.ts
Normal file
779
packages/shadcn/src/migrations/migrate-icons.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
1144
packages/shadcn/src/migrations/migrate-radix.test.ts
Normal file
1144
packages/shadcn/src/migrations/migrate-radix.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
458
packages/shadcn/src/migrations/migrate-radix.ts
Normal file
458
packages/shadcn/src/migrations/migrate-radix.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
125
packages/shadcn/src/migrations/migrate-rtl.test.ts
Normal file
125
packages/shadcn/src/migrations/migrate-rtl.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
})
|
||||
163
packages/shadcn/src/migrations/migrate-rtl.ts
Normal file
163
packages/shadcn/src/migrations/migrate-rtl.ts
Normal 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.`)
|
||||
}
|
||||
}
|
||||
74
packages/shadcn/src/preflights/preflight-add.ts
Normal file
74
packages/shadcn/src/preflights/preflight-add.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
82
packages/shadcn/src/preflights/preflight-apply.ts
Normal file
82
packages/shadcn/src/preflights/preflight-apply.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
46
packages/shadcn/src/preflights/preflight-build.ts
Normal file
46
packages/shadcn/src/preflights/preflight-build.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
140
packages/shadcn/src/preflights/preflight-init.test.ts
Normal file
140
packages/shadcn/src/preflights/preflight-init.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
194
packages/shadcn/src/preflights/preflight-init.ts
Normal file
194
packages/shadcn/src/preflights/preflight-init.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
48
packages/shadcn/src/preflights/preflight-migrate.test.ts
Normal file
48
packages/shadcn/src/preflights/preflight-migrate.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
63
packages/shadcn/src/preflights/preflight-migrate.ts
Normal file
63
packages/shadcn/src/preflights/preflight-migrate.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
66
packages/shadcn/src/preflights/preflight-registry.ts
Normal file
66
packages/shadcn/src/preflights/preflight-registry.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
131
packages/shadcn/src/preset/defaults.ts
Normal file
131
packages/shadcn/src/preset/defaults.ts
Normal 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
|
||||
}
|
||||
>
|
||||
28
packages/shadcn/src/preset/index.ts
Normal file
28
packages/shadcn/src/preset/index.ts
Normal 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"
|
||||
305
packages/shadcn/src/preset/preset.test.ts
Normal file
305
packages/shadcn/src/preset/preset.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
305
packages/shadcn/src/preset/preset.ts
Normal file
305
packages/shadcn/src/preset/preset.ts
Normal 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())
|
||||
}
|
||||
181
packages/shadcn/src/preset/presets.test.ts
Normal file
181
packages/shadcn/src/preset/presets.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
285
packages/shadcn/src/preset/presets.ts
Normal file
285
packages/shadcn/src/preset/presets.ts
Normal 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
|
||||
}
|
||||
}
|
||||
349
packages/shadcn/src/preset/resolve.test.ts
Normal file
349
packages/shadcn/src/preset/resolve.test.ts
Normal 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!))
|
||||
})
|
||||
})
|
||||
812
packages/shadcn/src/preset/resolve.ts
Normal file
812
packages/shadcn/src/preset/resolve.ts
Normal 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
|
||||
}
|
||||
201
packages/shadcn/src/registry/add.integration.test.ts
Normal file
201
packages/shadcn/src/registry/add.integration.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
452
packages/shadcn/src/registry/add.test.ts
Normal file
452
packages/shadcn/src/registry/add.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
113
packages/shadcn/src/registry/add.ts
Normal file
113
packages/shadcn/src/registry/add.ts
Normal 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])
|
||||
})
|
||||
}
|
||||
180
packages/shadcn/src/registry/address.test.ts
Normal file
180
packages/shadcn/src/registry/address.test.ts
Normal 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
|
||||
)
|
||||
})
|
||||
})
|
||||
182
packages/shadcn/src/registry/address.ts
Normal file
182
packages/shadcn/src/registry/address.ts
Normal 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)
|
||||
)
|
||||
}
|
||||
2479
packages/shadcn/src/registry/api.test.ts
Normal file
2479
packages/shadcn/src/registry/api.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
456
packages/shadcn/src/registry/api.ts
Normal file
456
packages/shadcn/src/registry/api.ts
Normal 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
|
||||
)
|
||||
}
|
||||
492
packages/shadcn/src/registry/builder.test.ts
Normal file
492
packages/shadcn/src/registry/builder.test.ts
Normal 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¶m2=value2",
|
||||
params: {
|
||||
newParam: "newValue",
|
||||
envParam: "${TEST_TOKEN}",
|
||||
},
|
||||
}
|
||||
|
||||
const url = buildUrlFromRegistryConfig("table", config)
|
||||
expect(url).toBe(
|
||||
"https://api.com/table?param1=value1¶m2=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`
|
||||
)
|
||||
})
|
||||
})
|
||||
168
packages/shadcn/src/registry/builder.ts
Normal file
168
packages/shadcn/src/registry/builder.ts
Normal 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}`
|
||||
}
|
||||
221
packages/shadcn/src/registry/config.test.ts
Normal file
221
packages/shadcn/src/registry/config.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
37
packages/shadcn/src/registry/config.ts
Normal file
37
packages/shadcn/src/registry/config.ts
Normal 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 },
|
||||
})
|
||||
)
|
||||
}
|
||||
203
packages/shadcn/src/registry/constants.ts
Normal file
203
packages/shadcn/src/registry/constants.ts
Normal 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"],
|
||||
},
|
||||
]
|
||||
81
packages/shadcn/src/registry/context.test.ts
Normal file
81
packages/shadcn/src/registry/context.test.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
65
packages/shadcn/src/registry/context.ts
Normal file
65
packages/shadcn/src/registry/context.ts
Normal 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 = {}
|
||||
}
|
||||
46
packages/shadcn/src/registry/env.test.ts
Normal file
46
packages/shadcn/src/registry/env.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
20
packages/shadcn/src/registry/env.ts
Normal file
20
packages/shadcn/src/registry/env.ts
Normal 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
|
||||
}
|
||||
478
packages/shadcn/src/registry/errors.ts
Normal file
478
packages/shadcn/src/registry/errors.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
396
packages/shadcn/src/registry/fetcher.test.ts
Normal file
396
packages/shadcn/src/registry/fetcher.test.ts
Normal 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" })
|
||||
})
|
||||
})
|
||||
176
packages/shadcn/src/registry/fetcher.ts
Normal file
176
packages/shadcn/src/registry/fetcher.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
94
packages/shadcn/src/registry/github-auth.ts
Normal file
94
packages/shadcn/src/registry/github-auth.ts
Normal 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"
|
||||
}`
|
||||
}
|
||||
90
packages/shadcn/src/registry/github-cli.integration.test.ts
Normal file
90
packages/shadcn/src/registry/github-cli.integration.test.ts
Normal 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" })
|
||||
})
|
||||
})
|
||||
541
packages/shadcn/src/registry/github-cli.test.ts
Normal file
541
packages/shadcn/src/registry/github-cli.test.ts
Normal 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
Reference in New Issue
Block a user