Compare commits

..

1 Commits

Author SHA1 Message Date
shadcn
f5b964460f feat(shadcn): experiment append action 2025-04-17 14:00:25 +04:00
25 changed files with 452 additions and 821 deletions

View File

@@ -73,7 +73,7 @@
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "2.15.1", "recharts": "2.15.1",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"shadcn": "2.5.0", "shadcn": "2.4.1",
"sonner": "^2.0.0", "sonner": "^2.0.0",
"tailwind-merge": "^3.0.1", "tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.7", "tailwindcss": "^4.0.7",

View File

@@ -6,9 +6,9 @@
"name": "index", "name": "index",
"type": "registry:style", "type": "registry:style",
"dependencies": [ "dependencies": [
"tw-animate-css",
"class-variance-authority", "class-variance-authority",
"lucide-react", "lucide-react"
"tw-animate-css"
], ],
"registryDependencies": [ "registryDependencies": [
"utils" "utils"
@@ -27,7 +27,37 @@
"path": "registry/new-york-v4/ui/accordion.tsx", "path": "registry/new-york-v4/ui/accordion.tsx",
"type": "registry:ui" "type": "registry:ui"
} }
] ],
"tailwind": {
"config": {
"theme": {
"extend": {
"keyframes": {
"accordion-down": {
"from": {
"height": "0"
},
"to": {
"height": "var(--radix-accordion-content-height)"
}
},
"accordion-up": {
"from": {
"height": "var(--radix-accordion-content-height)"
},
"to": {
"height": "0"
}
}
},
"animation": {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out"
}
}
}
}
}
}, },
{ {
"name": "alert", "name": "alert",

View File

@@ -21,9 +21,9 @@ const registry = {
name: "index", name: "index",
type: "registry:style", type: "registry:style",
dependencies: [ dependencies: [
"tw-animate-css",
"class-variance-authority", "class-variance-authority",
"lucide-react", "lucide-react",
"tw-animate-css",
], ],
registryDependencies: ["utils"], registryDependencies: ["utils"],
cssVars: {}, cssVars: {},

View File

@@ -321,7 +321,7 @@ I've been working on a new CLI for the past few weeks. It's a complete rewrite.
### `init` ### `init`
```bash ```bash
npx shadcn@latest init npx shadcn-ui@latest init
``` ```
When you run the `init` command, you will be asked a few questions to configure `components.json`: When you run the `init` command, you will be asked a few questions to configure `components.json`:
@@ -363,7 +363,7 @@ This means you can now use the CLI with any directory structure including `src`
### `add` ### `add`
```bash ```bash
npx shadcn@latest add npx shadcn-ui@latest add
``` ```
The `add` command is now much more capable. You can now add UI components but also import more complex components (coming soon). The `add` command is now much more capable. You can now add UI components but also import more complex components (coming soon).
@@ -373,7 +373,7 @@ The CLI will automatically resolve all components and dependencies, format them
### `diff` (experimental) ### `diff` (experimental)
```bash ```bash
npx shadcn diff npx shadcn-ui diff
``` ```
We're also introducing a new `diff` command to help you keep track of upstream updates. We're also introducing a new `diff` command to help you keep track of upstream updates.
@@ -383,7 +383,7 @@ You can use this command to see what has changed in the upstream repository and
Run the `diff` command to get a list of components that have updates available: Run the `diff` command to get a list of components that have updates available:
```bash ```bash
npx shadcn diff npx shadcn-ui diff
``` ```
```txt ```txt
@@ -398,7 +398,7 @@ The following components have updates available:
Then run `diff [component]` to see the changes: Then run `diff [component]` to see the changes:
```bash ```bash
npx shadcn diff alert npx shadcn-ui diff alert
``` ```
```diff /pl-12/ ```diff /pl-12/

View File

@@ -79,7 +79,7 @@ export const onCreateWebpackConfig = ({ actions }) => {
### Run the CLI ### Run the CLI
Run the `shadcn` init command to setup your project: Run the `shadcn-ui` init command to setup your project:
```bash ```bash
npx shadcn@latest init npx shadcn@latest init

View File

@@ -13,7 +13,7 @@ npx create-react-router@latest my-app
### Run the CLI ### Run the CLI
Run the `shadcn` init command to setup your project: Run the `shadcn-ui` init command to setup your project:
```bash ```bash
npx shadcn@latest init npx shadcn@latest init

View File

@@ -26,7 +26,7 @@ npx create-remix@latest my-app
### Run the CLI ### Run the CLI
Run the `shadcn` init command to setup your project: Run the `shadcn-ui` init command to setup your project:
```bash ```bash
npx shadcn@latest init npx shadcn@latest init

View File

@@ -86,7 +86,7 @@
"react-resizable-panels": "^2.0.22", "react-resizable-panels": "^2.0.22",
"react-wrap-balancer": "^0.4.1", "react-wrap-balancer": "^0.4.1",
"recharts": "2.12.7", "recharts": "2.12.7",
"shadcn": "2.5.0", "shadcn": "2.4.1",
"sharp": "^0.32.6", "sharp": "^0.32.6",
"sonner": "^1.2.3", "sonner": "^1.2.3",
"swr": "2.2.6-beta.3", "swr": "2.2.6-beta.3",

View File

@@ -3,9 +3,9 @@
"name": "index", "name": "index",
"type": "registry:style", "type": "registry:style",
"dependencies": [ "dependencies": [
"tw-animate-css",
"class-variance-authority", "class-variance-authority",
"lucide-react", "lucide-react"
"tw-animate-css"
], ],
"registryDependencies": [ "registryDependencies": [
"utils" "utils"

View File

@@ -85,6 +85,11 @@
"target": { "target": {
"type": "string", "type": "string",
"description": "The target path of the file. This is the path to the file in the project." "description": "The target path of the file. This is the path to the file in the project."
},
"action": {
"type": "string",
"enum": ["append", "prepend"],
"description": "The action to perform on the target file. Can be append or prepend."
} }
}, },
"if": { "if": {

View File

@@ -1,17 +1,5 @@
# @shadcn/ui # @shadcn/ui
## 2.5.0
### Minor Changes
- [#7220](https://github.com/shadcn-ui/ui/pull/7220) [`d0306774fe0ecc1eae9ef1e918bf7862e866a9e8`](https://github.com/shadcn-ui/ui/commit/d0306774fe0ecc1eae9ef1e918bf7862e866a9e8) Thanks [@shadcn](https://github.com/shadcn)! - resolve imports from anywhere
### Patch Changes
- [#6985](https://github.com/shadcn-ui/ui/pull/6985) [`f1e5cc4666ced2166a859660d769ccee16cde46e`](https://github.com/shadcn-ui/ui/commit/f1e5cc4666ced2166a859660d769ccee16cde46e) Thanks [@nrjdalal](https://github.com/nrjdalal)! - move tw-animate-css to devDependencies
- [#6899](https://github.com/shadcn-ui/ui/pull/6899) [`6f702f5fbf2b82a388e7da6ea08bcc84c2ec19c6`](https://github.com/shadcn-ui/ui/commit/6f702f5fbf2b82a388e7da6ea08bcc84c2ec19c6) Thanks [@justjavac](https://github.com/justjavac)! - add deno support
## 2.4.1 ## 2.4.1
### Patch Changes ### Patch Changes

View File

@@ -1,6 +1,6 @@
{ {
"name": "shadcn", "name": "shadcn",
"version": "2.5.0", "version": "2.4.1",
"description": "Add components to your apps.", "description": "Add components to your apps.",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"

View File

@@ -65,6 +65,10 @@ export const build = new Command()
// Loop through each file in the files array. // Loop through each file in the files array.
for (const file of registryItem.files) { for (const file of registryItem.files) {
if (file["content"]) {
continue
}
file["content"] = await fs.readFile( file["content"] = await fs.readFile(
path.resolve(resolvePaths.cwd, file.path), path.resolve(resolvePaths.cwd, file.path),
"utf-8" "utf-8"

View File

@@ -98,7 +98,7 @@ export async function migrateIcons(config: Config) {
} }
if (targetLibrary.package) { if (targetLibrary.package) {
await updateDependencies([targetLibrary.package], [], config, { await updateDependencies([targetLibrary.package], config, {
silent: false, silent: false,
}) })
} }

View File

@@ -19,6 +19,10 @@ export const registryItemTypeSchema = z.enum([
"registry:internal", "registry:internal",
]) ])
export const registryItemFileActionSchema = z
.enum(["append", "prepend"])
.optional()
export const registryItemFileSchema = z.discriminatedUnion("type", [ export const registryItemFileSchema = z.discriminatedUnion("type", [
// Target is required for registry:file and registry:page // Target is required for registry:file and registry:page
z.object({ z.object({
@@ -26,12 +30,14 @@ export const registryItemFileSchema = z.discriminatedUnion("type", [
content: z.string().optional(), content: z.string().optional(),
type: z.enum(["registry:file", "registry:page"]), type: z.enum(["registry:file", "registry:page"]),
target: z.string(), target: z.string(),
action: registryItemFileActionSchema,
}), }),
z.object({ z.object({
path: z.string(), path: z.string(),
content: z.string().optional(), content: z.string().optional(),
type: registryItemTypeSchema.exclude(["registry:file", "registry:page"]), type: registryItemTypeSchema.exclude(["registry:file", "registry:page"]),
target: z.string().optional(), target: z.string().optional(),
action: registryItemFileActionSchema,
}), }),
]) ])

View File

@@ -103,7 +103,7 @@ async function addProjectComponents(
silent: options.silent, silent: options.silent,
}) })
await updateDependencies(tree.dependencies, tree.devDependencies, config, { await updateDependencies(tree.dependencies, config, {
silent: options.silent, silent: options.silent,
}) })
await updateFiles(tree.files, config, { await updateFiles(tree.files, config, {
@@ -213,14 +213,9 @@ async function addWorkspaceComponents(
} }
// 4. Update dependencies. // 4. Update dependencies.
await updateDependencies( await updateDependencies(component.dependencies, targetConfig, {
component.dependencies, silent: true,
component.devDependencies, })
targetConfig,
{
silent: true,
}
)
// 5. Update files. // 5. Update files.
const files = await updateFiles(component.files, targetConfig, { const files = await updateFiles(component.files, targetConfig, {

View File

@@ -9,16 +9,13 @@ import prompts from "prompts"
export async function updateDependencies( export async function updateDependencies(
dependencies: RegistryItem["dependencies"], dependencies: RegistryItem["dependencies"],
devDependencies: RegistryItem["devDependencies"],
config: Config, config: Config,
options: { options: {
silent?: boolean silent?: boolean
} }
) { ) {
dependencies = Array.from(new Set(dependencies)) dependencies = Array.from(new Set(dependencies))
devDependencies = Array.from(new Set(devDependencies)) if (!dependencies?.length) {
if (!dependencies?.length && !devDependencies?.length) {
return return
} }
@@ -62,44 +59,23 @@ export async function updateDependencies(
dependenciesSpinner?.start() dependenciesSpinner?.start()
if (dependencies?.length) { await execa(
await execa( packageManager,
packageManager, [
[ packageManager === "npm" ? "install" : "add",
packageManager === "npm" ? "install" : "add", ...(packageManager === "npm" && flag ? [`--${flag}`] : []),
...(packageManager === "npm" && flag ? [`--${flag}`] : []), ...dependencies,
...(packageManager === "deno" ],
? dependencies.map((dep) => `npm:${dep}`) {
: dependencies), cwd: config.resolvedPaths.cwd,
], }
{ )
cwd: config.resolvedPaths.cwd,
}
)
}
if (devDependencies?.length) {
await execa(
packageManager,
[
packageManager === "npm" ? "install" : "add",
...(packageManager === "npm" && flag ? [`--${flag}`] : []),
"-D",
...(packageManager === "deno"
? devDependencies.map((dep) => `npm:${dep}`)
: devDependencies),
],
{
cwd: config.resolvedPaths.cwd,
}
)
}
dependenciesSpinner?.succeed() dependenciesSpinner?.succeed()
} }
function isUsingReact19(config: Config) { function isUsingReact19(config: Config) {
const packageInfo = getPackageInfo(config.resolvedPaths.cwd, false) const packageInfo = getPackageInfo(config.resolvedPaths.cwd)
if (!packageInfo?.dependencies?.react) { if (!packageInfo?.dependencies?.react) {
return false return false

View File

@@ -1,13 +1,15 @@
import { existsSync, promises as fs } from "fs" import { existsSync, promises as fs } from "fs"
import { tmpdir } from "os"
import path, { basename } from "path" import path, { basename } from "path"
import { getRegistryBaseColor } from "@/src/registry/api" import { getRegistryBaseColor } from "@/src/registry/api"
import { RegistryItem, registryItemFileSchema } from "@/src/registry/schema" import {
RegistryItem,
registryItemFileActionSchema,
registryItemFileSchema,
} from "@/src/registry/schema"
import { Config } from "@/src/utils/get-config" import { Config } from "@/src/utils/get-config"
import { ProjectInfo, getProjectInfo } from "@/src/utils/get-project-info" import { ProjectInfo, getProjectInfo } from "@/src/utils/get-project-info"
import { highlighter } from "@/src/utils/highlighter" import { highlighter } from "@/src/utils/highlighter"
import { logger } from "@/src/utils/logger" import { logger } from "@/src/utils/logger"
import { resolveImport } from "@/src/utils/resolve-import"
import { spinner } from "@/src/utils/spinner" import { spinner } from "@/src/utils/spinner"
import { transform } from "@/src/utils/transformers" import { transform } from "@/src/utils/transformers"
import { transformCssVars } from "@/src/utils/transformers/transform-css-vars" import { transformCssVars } from "@/src/utils/transformers/transform-css-vars"
@@ -16,10 +18,33 @@ import { transformImport } from "@/src/utils/transformers/transform-import"
import { transformRsc } from "@/src/utils/transformers/transform-rsc" import { transformRsc } from "@/src/utils/transformers/transform-rsc"
import { transformTwPrefixes } from "@/src/utils/transformers/transform-tw-prefix" import { transformTwPrefixes } from "@/src/utils/transformers/transform-tw-prefix"
import prompts from "prompts" import prompts from "prompts"
import { Project, ScriptKind } from "ts-morph"
import { loadConfig } from "tsconfig-paths"
import { z } from "zod" import { z } from "zod"
async function applyFileAction(
filePath: string,
content: string,
action: z.infer<typeof registryItemFileActionSchema>
) {
if (!action) {
return content
}
// Only try to read existing content if the file exists
if (existsSync(filePath)) {
const existingContent = await fs.readFile(filePath, "utf-8")
if (action === "append") {
return `${existingContent}\n${content}`
}
if (action === "prepend") {
return `${content}\n${existingContent}`
}
}
return content
}
export async function updateFiles( export async function updateFiles(
files: RegistryItem["files"], files: RegistryItem["files"],
config: Config, config: Config,
@@ -54,9 +79,9 @@ export async function updateFiles(
getRegistryBaseColor(config.tailwind.baseColor), getRegistryBaseColor(config.tailwind.baseColor),
]) ])
let filesCreated: string[] = [] const filesCreated = []
let filesUpdated: string[] = [] const filesUpdated = []
let filesSkipped: string[] = [] const filesSkipped = []
for (const file of files) { for (const file of files) {
if (!file.content) { if (!file.content) {
@@ -113,10 +138,29 @@ export async function updateFiles(
getNormalizedFileContent(existingFileContent), getNormalizedFileContent(existingFileContent),
getNormalizedFileContent(content), getNormalizedFileContent(content),
]) ])
// Check if content is already the same
if (normalizedExisting === normalizedNew) { if (normalizedExisting === normalizedNew) {
filesSkipped.push(path.relative(config.resolvedPaths.cwd, filePath)) filesSkipped.push(path.relative(config.resolvedPaths.cwd, filePath))
continue continue
} }
// Also check if action is already applied
if (file.action) {
if (file.action === "append") {
// Check if the content is already appended
if (normalizedExisting.endsWith(normalizedNew)) {
filesSkipped.push(path.relative(config.resolvedPaths.cwd, filePath))
continue
}
} else if (file.action === "prepend") {
// Check if the content is already prepended
if (normalizedExisting.startsWith(normalizedNew)) {
filesSkipped.push(path.relative(config.resolvedPaths.cwd, filePath))
continue
}
}
}
} }
if (existingFile && !options.overwrite) { if (existingFile && !options.overwrite) {
@@ -151,31 +195,31 @@ export async function updateFiles(
await fs.mkdir(targetDir, { recursive: true }) await fs.mkdir(targetDir, { recursive: true })
} }
await fs.writeFile(filePath, content, "utf-8") let finalContent = content
existingFile if (existingFile && file.action) {
? filesUpdated.push(path.relative(config.resolvedPaths.cwd, filePath)) const existingFileContent = await fs.readFile(filePath, "utf-8")
: filesCreated.push(path.relative(config.resolvedPaths.cwd, filePath)) if (file.action === "append") {
finalContent = `${existingFileContent}\n${content}`
} else if (file.action === "prepend") {
finalContent = `${content}\n${existingFileContent}`
}
}
await fs.writeFile(filePath, finalContent, "utf-8")
const relativePath = path.relative(config.resolvedPaths.cwd, filePath)
if (existingFile) {
filesUpdated.push(relativePath)
} else {
filesCreated.push(relativePath)
}
} }
const allFiles = [...filesCreated, ...filesUpdated, ...filesSkipped]
const updatedFiles = await resolveImports(allFiles, config)
// Let's update filesUpdated with the updated files.
filesUpdated.push(...updatedFiles)
// If a file is in filesCreated and filesUpdated, we should remove it from filesUpdated.
filesUpdated = filesUpdated.filter((file) => !filesCreated.includes(file))
const hasUpdatedFiles = filesCreated.length || filesUpdated.length const hasUpdatedFiles = filesCreated.length || filesUpdated.length
if (!hasUpdatedFiles && !filesSkipped.length) { if (!hasUpdatedFiles && !filesSkipped.length) {
filesCreatedSpinner?.info("No files updated.") filesCreatedSpinner?.info("No files updated.")
} }
// Remove duplicates.
filesCreated = Array.from(new Set(filesCreated))
filesUpdated = Array.from(new Set(filesUpdated))
filesSkipped = Array.from(new Set(filesSkipped))
if (filesCreated.length) { if (filesCreated.length) {
filesCreatedSpinner?.succeed( filesCreatedSpinner?.succeed(
`Created ${filesCreated.length} ${ `Created ${filesCreated.length} ${
@@ -210,7 +254,7 @@ export async function updateFiles(
if (filesSkipped.length) { if (filesSkipped.length) {
spinner( spinner(
`Skipped ${filesSkipped.length} ${ `Skipped ${filesSkipped.length} ${
filesUpdated.length === 1 ? "file" : "files" filesSkipped.length === 1 ? "file" : "files"
}: (files might be identical, use --overwrite to overwrite)`, }: (files might be identical, use --overwrite to overwrite)`,
{ {
silent: options.silent, silent: options.silent,
@@ -350,7 +394,10 @@ export function resolveNestedFilePath(
return fileSegments.slice(commonDirIndex + 1).join("/") return fileSegments.slice(commonDirIndex + 1).join("/")
} }
export async function getNormalizedFileContent(content: string) { export async function getNormalizedFileContent(content: string | undefined) {
if (!content) {
return ""
}
return content.replace(/\r\n/g, "\n").trim() return content.replace(/\r\n/g, "\n").trim()
} }
@@ -389,227 +436,3 @@ export function resolvePageTarget(
return "" return ""
} }
async function resolveImports(filePaths: string[], config: Config) {
const project = new Project({
compilerOptions: {},
})
const projectInfo = await getProjectInfo(config.resolvedPaths.cwd)
const tsConfig = await loadConfig(config.resolvedPaths.cwd)
const updatedFiles = []
if (!projectInfo || tsConfig.resultType === "failed") {
return []
}
for (const filepath of filePaths) {
const resolvedPath = path.resolve(config.resolvedPaths.cwd, filepath)
// Check if the file exists.
if (!existsSync(resolvedPath)) {
continue
}
const content = await fs.readFile(resolvedPath, "utf-8")
const dir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-"))
const sourceFile = project.createSourceFile(
path.join(dir, basename(resolvedPath)),
content,
{
scriptKind: ScriptKind.TSX,
}
)
const importDeclarations = sourceFile.getImportDeclarations()
for (const importDeclaration of importDeclarations) {
const moduleSpecifier = importDeclaration.getModuleSpecifierValue()
// Filter out non-local imports.
if (
projectInfo?.aliasPrefix &&
!moduleSpecifier.startsWith(`${projectInfo.aliasPrefix}/`)
) {
continue
}
// Find the probable import file path.
// This is where we expect to find the file on disk.
const probableImportFilePath = await resolveImport(
moduleSpecifier,
tsConfig
)
if (!probableImportFilePath) {
continue
}
// Find the actual import file path.
// This is the path where the file has been installed.
const resolvedImportFilePath = resolveModuleByProbablePath(
probableImportFilePath,
filePaths,
config
)
if (!resolvedImportFilePath) {
continue
}
// Convert the resolved import file path to an aliased import.
const newImport = toAliasedImport(
resolvedImportFilePath,
config,
projectInfo
)
if (!newImport || newImport === moduleSpecifier) {
continue
}
importDeclaration.setModuleSpecifier(newImport)
// Write the updated content to the file.
await fs.writeFile(resolvedPath, sourceFile.getFullText(), "utf-8")
// Track the updated file.
updatedFiles.push(filepath)
}
}
return updatedFiles
}
/**
* Given an absolute "probable" import path (no ext),
* plus an array of absolute file paths you already know about,
* return 0N matches (best match first), and also check disk for any missing ones.
*/
export function resolveModuleByProbablePath(
probableImportFilePath: string,
files: string[],
config: Config,
extensions: string[] = [".tsx", ".ts", ".js", ".jsx", ".css"]
) {
const cwd = path.normalize(config.resolvedPaths.cwd)
// 1) Build a set of POSIX-normalized, project-relative files
const relativeFiles = files.map((f) => f.split(path.sep).join(path.posix.sep))
const fileSet = new Set(relativeFiles)
// 2) Strip any existing extension off the absolute base path
const extInPath = path.extname(probableImportFilePath)
const hasExt = extInPath !== ""
const absBase = hasExt
? probableImportFilePath.slice(0, -extInPath.length)
: probableImportFilePath
// 3) Compute the project-relative "base" directory for strong matching
const relBaseRaw = path.relative(cwd, absBase)
const relBase = relBaseRaw.split(path.sep).join(path.posix.sep)
// 4) Decide which extensions to try
const tryExts = hasExt ? [extInPath] : extensions
// 5) Collect candidates
const candidates = new Set<string>()
// 5a) Fastpath: [base + ext] and [base/index + ext]
for (const e of tryExts) {
const absCand = absBase + e
const relCand = path.posix.normalize(path.relative(cwd, absCand))
if (fileSet.has(relCand) || existsSync(absCand)) {
candidates.add(relCand)
}
const absIdx = path.join(absBase, `index${e}`)
const relIdx = path.posix.normalize(path.relative(cwd, absIdx))
if (fileSet.has(relIdx) || existsSync(absIdx)) {
candidates.add(relIdx)
}
}
// 5b) Fallback: scan known files by basename
const name = path.basename(absBase)
for (const f of relativeFiles) {
if (tryExts.some((e) => f.endsWith(`/${name}${e}`))) {
candidates.add(f)
}
}
// 6) If no matches, bail
if (candidates.size === 0) return null
// 7) Sort by (1) extension priority, then (2) "strong" base match
const sorted = Array.from(candidates).sort((a, b) => {
// a) extension order
const aExt = path.posix.extname(a)
const bExt = path.posix.extname(b)
const ord = tryExts.indexOf(aExt) - tryExts.indexOf(bExt)
if (ord !== 0) return ord
// b) strong match if path starts with relBase
const aStrong = relBase && a.startsWith(relBase) ? -1 : 1
const bStrong = relBase && b.startsWith(relBase) ? -1 : 1
return aStrong - bStrong
})
// 8) Return the first (best) candidate
return sorted[0]
}
export function toAliasedImport(
filePath: string,
config: Config,
projectInfo: ProjectInfo
): string | null {
const abs = path.normalize(path.join(config.resolvedPaths.cwd, filePath))
// 1⃣ Find the longest matching alias root in resolvedPaths
// e.g. key="ui", root="/…/components/ui" beats key="components"
const matches = Object.entries(config.resolvedPaths)
.filter(
([, root]) => root && abs.startsWith(path.normalize(root + path.sep))
)
.sort((a, b) => b[1].length - a[1].length)
if (matches.length === 0) {
return null
}
const [aliasKey, rootDir] = matches[0]
// 2⃣ Compute the path UNDER that root
let rel = path.relative(rootDir, abs)
// force POSIX-style separators
rel = rel.split(path.sep).join("/") // e.g. "button/index.tsx"
// 3⃣ Strip code-file extensions, keep others (css, json, etc.)
const ext = path.posix.extname(rel)
const codeExts = [".ts", ".tsx", ".js", ".jsx"]
const keepExt = codeExts.includes(ext) ? "" : ext
let noExt = rel.slice(0, rel.length - ext.length)
// 4⃣ Collapse "/index" to its directory
if (noExt.endsWith("/index")) {
noExt = noExt.slice(0, -"/index".length)
}
// 5⃣ Build the aliased path
// config.aliases[aliasKey] is e.g. "@/components/ui"
const aliasBase =
aliasKey === "cwd"
? projectInfo.aliasPrefix
: config.aliases[aliasKey as keyof typeof config.aliases]
if (!aliasBase) {
return null
}
// if noExt is empty (i.e. file was exactly at the root), we import the root
let suffix = noExt === "" ? "" : `/${noExt}`
// Rremove /src from suffix.
// Alias will handle this.
suffix = suffix.replace("/src", "")
// 6⃣ Prepend the prefix from projectInfo (e.g. "@") if needed
// but usually config.aliases already include it.
return `${aliasBase}${suffix}${keepExt}`
}

View File

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

View File

@@ -1,13 +0,0 @@
{
"name": "test-cli-npm-project",
"version": "1.0.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "npm-project",
"version": "1.0.0",
"license": "MIT"
}
}
}

View File

@@ -1,10 +0,0 @@
{
"name": "test-cli-project-npm",
"version": "1.0.0",
"main": "index.js",
"author": "shadcn",
"license": "MIT",
"dependencies": {
"react": "19.0.0"
}
}

View File

@@ -1,139 +0,0 @@
import { vi, describe, afterEach, test, expect } from "vitest"
import { execa } from "execa"
import prompts from "prompts"
import { updateDependencies } from "../../../src/utils/updaters/update-dependencies"
import path from "path"
vi.mock("execa")
vi.mock("prompts")
describe("updateDependencies", () => {
afterEach(() => {
vi.restoreAllMocks()
})
test.each([
{
description: "npm without react 19 includes no additional flags",
options: { silent: true },
dependencies: ["first", "second", "third"],
devDependencies: ["fourth"],
config: {
resolvedPaths: {
cwd: path.resolve(__dirname, "../../fixtures/project-npm")
}
},
expectedPackageManager: "npm",
expectedArgs: ["install", "first", "second", "third"],
expectedDevArgs: ["install", "-D", "fourth"]
},
{
description: "npm with react 19 applies force prompt when silent",
options: { silent: true },
dependencies: ["first", "second", "third"],
devDependencies: ["fourth"],
config: {
resolvedPaths: {
cwd: path.resolve(__dirname, "../../fixtures/project-npm-react19")
}
},
expectedPackageManager: "npm",
expectedArgs: ["install", "--force", "first", "second", "third"],
expectedDevArgs: ["install", "--force", "-D", "fourth"]
},
{
description: "npm with react 19 prompts for flag when not silent",
flagPrompt: "legacy-peer-deps",
dependencies: ["first", "second", "third"],
devDependencies: ["fourth"],
config: {
resolvedPaths: {
cwd: path.resolve(__dirname, "../../fixtures/project-npm-react19")
}
},
expectedPackageManager: "npm",
expectedArgs: ["install", "--legacy-peer-deps", "first", "second", "third"],
expectedDevArgs: ["install", "--legacy-peer-deps", "-D", "fourth"]
},
{
description: "deno uses npm: package prefix",
dependencies: ["first", "second", "third"],
devDependencies: ["fourth"],
config: {
resolvedPaths: {
cwd: path.resolve(__dirname, "../../fixtures/project-deno")
}
},
expectedPackageManager: "deno",
expectedArgs: ["add", "npm:first", "npm:second", "npm:third"],
expectedDevArgs: ["add", "-D", "npm:fourth"]
},
{
description: "bun uses bun",
dependencies: ["first", "second", "third"],
devDependencies: ["fourth"],
config: {
resolvedPaths: {
cwd: path.resolve(__dirname, "../../fixtures/project-bun")
}
},
expectedPackageManager: "bun",
expectedArgs: ["add", "first", "second", "third"],
expectedDevArgs: ["add", "-D", "fourth"]
},
{
description: "pnpm uses pnpm",
dependencies: ["first", "second", "third"],
devDependencies: ["fourth"],
config: {
resolvedPaths: {
cwd: path.resolve(__dirname, "../../fixtures/project-pnpm")
}
},
expectedPackageManager: "pnpm",
expectedArgs: ["add", "first", "second", "third"],
expectedDevArgs: ["add", "-D", "fourth"]
},
{
description: "deduplicates input dependencies",
options: { silent: true },
dependencies: ["first", "first"],
devDependencies: ["second", "second"],
config: {
resolvedPaths: {
cwd: path.resolve(__dirname, "../../fixtures/project-npm")
}
},
expectedPackageManager: "npm",
expectedArgs: ["install", "first"],
expectedDevArgs: ["install", "-D", "second"]
}
])("$description", async ({ options, flagPrompt, config, dependencies, devDependencies, expectedPackageManager, expectedArgs, expectedDevArgs }) => {
vi.mocked(prompts).mockResolvedValue({ flag: flagPrompt })
await updateDependencies(
dependencies,
devDependencies,
config,
options ?? {}
)
if (flagPrompt) {
expect(prompts).toHaveBeenCalled()
}
expect(execa).toHaveBeenCalledWith(
expectedPackageManager,
expectedArgs,
{ cwd: config?.resolvedPaths.cwd }
)
expect(execa).toHaveBeenCalledWith(
expectedPackageManager,
expectedDevArgs,
{ cwd: config?.resolvedPaths.cwd }
)
})
})

View File

@@ -1,23 +1,35 @@
import { existsSync } from "fs" import { existsSync, promises as fs } from "fs"
import path from "path" import path from "path"
import { afterAll, afterEach, describe, expect, test, vi } from "vitest" import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
test,
vi,
} from "vitest"
import { getConfig } from "../../../src/utils/get-config" import { getConfig } from "../../../src/utils/get-config"
import * as transformers from "../../../src/utils/transformers"
import { import {
findCommonRoot, findCommonRoot,
resolveFilePath, resolveFilePath,
resolveModuleByProbablePath,
resolveNestedFilePath, resolveNestedFilePath,
toAliasedImport,
updateFiles, updateFiles,
} from "../../../src/utils/updaters/update-files" } from "../../../src/utils/updaters/update-files"
vi.mock("../../../src/utils/transformers", () => ({
transform: vi.fn().mockImplementation((opts) => Promise.resolve(opts.raw)),
}))
vi.mock("fs/promises", async () => { vi.mock("fs/promises", async () => {
const actual = (await vi.importActual( const actual = (await vi.importActual(
"fs/promises" "fs/promises"
)) as typeof import("fs/promises") )) as typeof import("fs/promises")
return { return {
...actual, ...actual,
readFile: vi.fn(),
writeFile: vi.fn(), writeFile: vi.fn(),
} }
}) })
@@ -26,8 +38,10 @@ vi.mock("fs", async () => {
const actual = (await vi.importActual("fs")) as typeof import("fs") const actual = (await vi.importActual("fs")) as typeof import("fs")
return { return {
...actual, ...actual,
existsSync: vi.fn(),
promises: { promises: {
...actual.promises, ...actual.promises,
readFile: vi.fn(),
writeFile: vi.fn(), writeFile: vi.fn(),
}, },
} }
@@ -734,6 +748,30 @@ describe("updateFiles", () => {
const config = await getConfig( const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind") path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
) )
// Set up mocks for transform
vi.mocked(transformers.transform).mockImplementation((opts) => {
if (opts.raw.includes("Button")) {
return Promise.resolve(opts.raw)
}
return Promise.resolve(opts.raw)
})
// Mock existsSync to check for existing files
vi.mocked(existsSync).mockImplementation((path) => {
return path.toString().includes("button.tsx")
})
// Mock readFile to return content for comparison
vi.mocked(fs.readFile).mockImplementation((path) => {
if (path.toString().includes("button.tsx")) {
return Promise.resolve(`export function Button() {
return <button>Click me</button>
}`)
}
return Promise.resolve("")
})
expect( expect(
await updateFiles( await updateFiles(
[ [
@@ -775,6 +813,27 @@ return <div>Hello World</div>
const config = await getConfig( const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind") path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
) )
// Set up mocks for transform
vi.mocked(transformers.transform).mockImplementation((opts) => {
return Promise.resolve(opts.raw)
})
// Mock existsSync to check for existing files
vi.mocked(existsSync).mockImplementation((path) => {
return path.toString().includes("button.tsx")
})
// Mock readFile to return content for comparison
vi.mocked(fs.readFile).mockImplementation((path) => {
if (path.toString().includes("button.tsx")) {
return Promise.resolve(`export function Button() {
return <button>I'm different</button>
}`)
}
return Promise.resolve("")
})
expect( expect(
await updateFiles( await updateFiles(
[ [
@@ -813,339 +872,247 @@ return <div>Hello World</div>
}) })
}) })
describe("resolveModuleByProbablePath", () => { describe("file actions", () => {
test("should resolve exact file match in provided files list", () => { test("should append content to existing file", async () => {
const files = [ // Set up mocks
"components/button.tsx", vi.mocked(transformers.transform).mockResolvedValue("new-content")
"components/card.tsx", vi.mocked(existsSync)
"lib/utils.ts", .mockReturnValueOnce(true) // First check for file existence
] .mockReturnValueOnce(true) // Directory exists check
const config = {
resolvedPaths: { const existingContent = "existing-content"
cwd: "/foo/bar", vi.mocked(fs.readFile)
}, .mockResolvedValueOnce(existingContent) // For content comparison check
} .mockResolvedValueOnce(existingContent) // For append operation
expect(
resolveModuleByProbablePath("/foo/bar/components/button", files, config) const config = await getConfig(
).toBe("components/button.tsx") path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
)
const result = await updateFiles(
[
{
path: "components/test.tsx",
type: "registry:component",
content: "original-content", // This will be transformed to "new-content"
action: "append",
},
],
config,
{
overwrite: true,
silent: true,
}
)
// Check the file was updated not created
expect(result.filesUpdated).toHaveLength(1)
expect(result.filesCreated).toHaveLength(0)
expect(result.filesSkipped).toHaveLength(0)
// Check writeFile was called correctly for append
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
`${existingContent}\nnew-content`,
"utf-8"
)
}) })
test("should resolve index file", () => { test("should prepend content to existing file", async () => {
const files = ["components/button/index.tsx", "components/card.tsx"] // Set up mocks
const config = { vi.mocked(transformers.transform).mockResolvedValue("new-content")
resolvedPaths: { vi.mocked(existsSync)
cwd: "/foo/bar", .mockReturnValueOnce(true) // First check for file existence
}, .mockReturnValueOnce(true) // Directory exists check
}
expect( const existingContent = "existing-content"
resolveModuleByProbablePath("/foo/bar/components/button", files, config) vi.mocked(fs.readFile)
).toBe("components/button/index.tsx") .mockResolvedValueOnce(existingContent) // For content comparison check
.mockResolvedValueOnce(existingContent) // For prepend operation
const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
)
const result = await updateFiles(
[
{
path: "components/test.tsx",
type: "registry:component",
content: "original-content", // This will be transformed to "new-content"
action: "prepend",
},
],
config,
{
overwrite: true,
silent: true,
}
)
// Check the file was updated not created
expect(result.filesUpdated).toHaveLength(1)
expect(result.filesCreated).toHaveLength(0)
expect(result.filesSkipped).toHaveLength(0)
// Check writeFile was called correctly for prepend
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
`new-content\n${existingContent}`,
"utf-8"
)
}) })
test("should try different extensions", () => { test("should update file when content is different", async () => {
const files = ["components/button.jsx", "components/card.tsx"] // Set up mocks
const config = { vi.mocked(transformers.transform).mockResolvedValue("new-content")
resolvedPaths: { vi.mocked(existsSync)
cwd: "/foo/bar", .mockReturnValueOnce(true) // First check for file existence
}, .mockReturnValueOnce(true) // Directory exists check
}
expect( const existingContent = "existing-content"
resolveModuleByProbablePath("/foo/bar/components/button", files, config) vi.mocked(fs.readFile).mockResolvedValueOnce(existingContent) // For content comparison
).toBe("components/button.jsx")
const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
)
const result = await updateFiles(
[
{
path: "components/test.tsx",
type: "registry:component",
content: "original-content", // This will be transformed to "new-content"
},
],
config,
{
overwrite: true,
silent: true,
}
)
// Check the file was updated not created
expect(result.filesUpdated).toHaveLength(1)
expect(result.filesCreated).toHaveLength(0)
expect(result.filesSkipped).toHaveLength(0)
// Check writeFile was called with new content
expect(fs.writeFile).toHaveBeenCalledWith(
expect.any(String),
"new-content",
"utf-8"
)
}) })
test("should fallback to basename matching", () => { test("should skip file when content is the same", async () => {
const files = ["components/ui/button.tsx", "components/card.tsx"] // Set up mocks
const config = { const content = "same-content"
resolvedPaths: { vi.mocked(transformers.transform).mockResolvedValue(content)
cwd: "/foo/bar", vi.mocked(existsSync).mockReturnValue(true)
}, vi.mocked(fs.readFile).mockResolvedValue(content)
}
expect( const config = await getConfig(
resolveModuleByProbablePath("/foo/bar/components/button", files, config) path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
).toBe("components/ui/button.tsx") )
const result = await updateFiles(
[
{
path: "components/test.tsx",
type: "registry:component",
content,
},
],
config,
{
overwrite: true,
silent: true,
}
)
// Check the file was skipped
expect(result.filesSkipped).toHaveLength(1)
expect(result.filesUpdated).toHaveLength(0)
expect(result.filesCreated).toHaveLength(0)
// Verify writeFile was not called
expect(fs.writeFile).not.toHaveBeenCalled()
}) })
test("should return null when file not found", () => { test("should skip file when content is already appended", async () => {
const files = ["components/card.tsx", "lib/utils.ts"] // Set up mocks
const config = { const newContent = "new-content"
resolvedPaths: { const existingContent = "existing-content\nnew-content" // Already has the content appended
cwd: "/foo/bar",
}, vi.mocked(transformers.transform).mockResolvedValue(newContent)
} vi.mocked(existsSync).mockReturnValue(true)
expect( vi.mocked(fs.readFile).mockResolvedValue(existingContent)
resolveModuleByProbablePath("/foo/bar/components/button", files, config)
).toBeNull() const config = await getConfig(
path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
)
const result = await updateFiles(
[
{
path: "components/test.tsx",
type: "registry:component",
content: newContent,
action: "append",
},
],
config,
{
overwrite: true,
silent: true,
}
)
// The file should be skipped because the content is already appended
expect(result.filesSkipped).toHaveLength(1)
expect(result.filesUpdated).toHaveLength(0)
expect(result.filesCreated).toHaveLength(0)
// Verify writeFile was not called
expect(fs.writeFile).not.toHaveBeenCalled()
}) })
test("should sort by extension priority", () => { test("should skip file when content is already prepended", async () => {
const files = [ // Set up mocks
"components/button.jsx", const newContent = "new-content"
"components/button.tsx", const existingContent = "new-content\nexisting-content" // Already has the content prepended
"components/button.js",
]
const config = {
resolvedPaths: {
cwd: "/foo/bar",
},
}
expect(
resolveModuleByProbablePath("/foo/bar/components/button", files, config, [
".tsx",
".jsx",
".js",
])
).toBe("components/button.tsx")
})
test("should preserve extension if specified in path", () => { vi.mocked(transformers.transform).mockResolvedValue(newContent)
const files = ["components/button.tsx", "components/button.css"] vi.mocked(existsSync).mockReturnValue(true)
const config = { vi.mocked(fs.readFile).mockResolvedValue(existingContent)
resolvedPaths: {
cwd: "/foo/bar", const config = await getConfig(
}, path.resolve(__dirname, "../../fixtures/vite-with-tailwind")
} )
expect(
resolveModuleByProbablePath( const result = await updateFiles(
"/foo/bar/components/button.css", [
files, {
config path: "components/test.tsx",
) type: "registry:component",
).toBe("components/button.css") content: newContent,
}) action: "prepend",
}) },
],
describe("toAliasedImport", () => { config,
test("should convert components path to aliased import", () => { {
const filePath = "components/button.tsx" overwrite: true,
const config = { silent: true,
resolvedPaths: { }
cwd: "/foo/bar", )
components: "/foo/bar/components",
ui: "/foo/bar/components/ui", // The file should be skipped because the content is already prepended
lib: "/foo/bar/lib", expect(result.filesSkipped).toHaveLength(1)
}, expect(result.filesUpdated).toHaveLength(0)
aliases: { expect(result.filesCreated).toHaveLength(0)
components: "@/components",
ui: "@/components/ui", // Verify writeFile was not called
lib: "@/lib", expect(fs.writeFile).not.toHaveBeenCalled()
},
}
const projectInfo = {
aliasPrefix: "@",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe(
"@/components/button"
)
})
test("should convert ui path to aliased import", () => {
const filePath = "components/ui/button.tsx"
const config = {
resolvedPaths: {
cwd: "/foo/bar",
components: "/foo/bar/components",
ui: "/foo/bar/components/ui",
lib: "/foo/bar/lib",
},
aliases: {
components: "@/components",
ui: "@/components/ui",
lib: "@/lib",
},
}
const projectInfo = {
aliasPrefix: "@",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe(
"@/components/ui/button"
)
})
test("should collapse index files", () => {
const filePath = "components/ui/button/index.tsx"
const config = {
resolvedPaths: {
cwd: "/foo/bar",
components: "/foo/bar/components",
ui: "/foo/bar/components/ui",
lib: "/foo/bar/lib",
},
aliases: {
components: "@/components",
ui: "@/components/ui",
lib: "@/lib",
},
}
const projectInfo = {
aliasPrefix: "@",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe(
"@/components/ui/button"
)
})
test("should return null when no matching alias found", () => {
const filePath = "src/pages/index.tsx"
const config = {
resolvedPaths: {
cwd: "/foo/bar",
components: "/foo/bar/components",
ui: "/foo/bar/components/ui",
lib: "/foo/bar/lib",
},
aliases: {
components: "@/components",
ui: "@/components/ui",
lib: "@/lib",
},
}
const projectInfo = {
aliasPrefix: "@",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe("@/pages")
})
test("should handle nested directories", () => {
const filePath = "components/forms/inputs/text-input.tsx"
const config = {
resolvedPaths: {
cwd: "/foo/bar",
components: "/foo/bar/components",
ui: "/foo/bar/components/ui",
lib: "/foo/bar/lib",
},
aliases: {
components: "@/components",
ui: "@/components/ui",
lib: "@/lib",
},
}
const projectInfo = {
aliasPrefix: "@",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe(
"@/components/forms/inputs/text-input"
)
})
test("should keep non-code file extensions", () => {
const filePath = "components/styles/theme.css"
const config = {
resolvedPaths: {
cwd: "/foo/bar",
components: "/foo/bar/components",
ui: "/foo/bar/components/ui",
lib: "/foo/bar/lib",
},
aliases: {
components: "@/components",
ui: "@/components/ui",
lib: "@/lib",
},
}
const projectInfo = {
aliasPrefix: "@",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe(
"@/components/styles/theme.css"
)
})
test("should prefer longer matching paths", () => {
const filePath = "components/ui/button.tsx"
const config = {
resolvedPaths: {
cwd: "/foo/bar",
components: "/foo/bar/components",
ui: "/foo/bar/components/ui",
},
aliases: {
components: "@/components",
ui: "@/ui",
},
}
const projectInfo = {
aliasPrefix: "@",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe("@/ui/button")
})
test("should support tilde (~) alias prefix", () => {
const filePath = "components/button.tsx"
const config = {
resolvedPaths: {
cwd: "/foo/bar",
components: "/foo/bar/components",
},
aliases: {
components: "~components",
},
}
const projectInfo = {
aliasPrefix: "~",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe(
"~components/button"
)
})
test("should support @shadcn alias prefix", () => {
const filePath = "components/ui/button.tsx"
const config = {
resolvedPaths: {
cwd: "/foo/bar",
components: "/foo/bar/components",
ui: "/foo/bar/components/ui",
},
aliases: {
components: "@shadcn/components",
ui: "@shadcn/ui",
},
}
const projectInfo = {
aliasPrefix: "@shadcn",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe(
"@shadcn/ui/button"
)
})
test("should support ~cn alias prefix", () => {
const filePath = "lib/utils/index.tsx"
const config = {
resolvedPaths: {
cwd: "/foo/bar",
lib: "/foo/bar/lib",
},
aliases: {
lib: "~cn/lib",
},
}
const projectInfo = {
aliasPrefix: "~cn",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe("~cn/lib/utils")
})
test("should use project alias prefix when aliasKey is cwd", () => {
const filePath = "src/pages/home.tsx"
const config = {
resolvedPaths: {
cwd: "/foo/bar",
components: "/foo/bar/components",
ui: "/foo/bar/components/ui",
lib: "/foo/bar/lib",
},
aliases: {
components: "@/components",
ui: "@/components/ui",
lib: "@/lib",
},
}
const projectInfo = {
aliasPrefix: "@",
}
expect(toAliasedImport(filePath, config, projectInfo)).toBe("@/pages/home")
}) })
}) })

4
pnpm-lock.yaml generated
View File

@@ -283,7 +283,7 @@ importers:
specifier: ^6.0.1 specifier: ^6.0.1
version: 6.0.1 version: 6.0.1
shadcn: shadcn:
specifier: 2.5.0 specifier: 2.4.1
version: link:../../packages/shadcn version: link:../../packages/shadcn
sonner: sonner:
specifier: ^2.0.0 specifier: ^2.0.0
@@ -542,7 +542,7 @@ importers:
specifier: 2.12.7 specifier: 2.12.7
version: 2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 2.12.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
shadcn: shadcn:
specifier: 2.5.0 specifier: 2.4.1
version: link:../../packages/shadcn version: link:../../packages/shadcn
sharp: sharp:
specifier: ^0.32.6 specifier: ^0.32.6