feat(shadcn): update file handling for monorepo (#7955)

* feat(shadcn): update monorepo handling

* feat(shadcn): update file handling for monorepo

* chore: changeset
This commit is contained in:
shadcn
2025-08-06 15:13:51 +04:00
committed by GitHub
parent 469250115f
commit a80ab37483
5 changed files with 445 additions and 109 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": minor
---
update file handling for monorepo

View File

@@ -1,12 +1,5 @@
import path from "path"
import {
fetchRegistry,
getRegistryItem,
getRegistryParentMap,
getRegistryTypeAliasMap,
registryResolveItemsTree,
resolveRegistryItems,
} from "@/src/registry/api"
import { getRegistryItem, registryResolveItemsTree } from "@/src/registry/api"
import {
configSchema,
registryItemFileSchema,
@@ -158,132 +151,143 @@ async function addWorkspaceComponents(
const registrySpinner = spinner(`Checking registry.`, {
silent: options.silent,
})?.start()
let registryItems = await resolveRegistryItems(components, config)
let result = await fetchRegistry(registryItems)
const payload = z.array(registryItemSchema).parse(result)
if (!payload) {
const tree = await registryResolveItemsTree(components, config)
if (!tree) {
registrySpinner?.fail()
return handleError(new Error("Failed to fetch components from registry."))
}
registrySpinner?.succeed()
const registryParentMap = getRegistryParentMap(payload)
const registryTypeAliasMap = getRegistryTypeAliasMap()
try {
validateFilesTarget(tree.files ?? [], config.resolvedPaths.cwd)
} catch (error) {
registrySpinner?.fail()
return handleError(error)
}
registrySpinner?.succeed()
const filesCreated: string[] = []
const filesUpdated: string[] = []
const filesSkipped: string[] = []
const files = payload.flatMap((item) => item.files ?? [])
try {
validateFilesTarget(files, config.resolvedPaths.cwd)
} catch (error) {
return handleError(error)
}
const rootSpinner = spinner(`Installing components.`)?.start()
for (const component of payload) {
const alias = registryTypeAliasMap.get(component.type)
const registryParent = registryParentMap.get(component.name)
// Process global updates (tailwind, css vars, dependencies) first for the main target.
// These should typically go to the UI package in a workspace.
const mainTargetConfig = workspaceConfig.ui
const tailwindVersion = await getProjectTailwindVersionFromConfig(
mainTargetConfig
)
const workspaceRoot = findCommonRoot(
config.resolvedPaths.cwd,
mainTargetConfig.resolvedPaths.ui
)
// We don't support this type of component.
if (!alias) {
continue
}
// A good start is ui for now.
// TODO: Add support for other types.
let targetConfig =
component.type === "registry:ui" || registryParent?.type === "registry:ui"
? workspaceConfig.ui
: config
const tailwindVersion = await getProjectTailwindVersionFromConfig(
targetConfig
// 1. Update tailwind config.
if (tree.tailwind?.config) {
await updateTailwindConfig(tree.tailwind?.config, mainTargetConfig, {
silent: true,
tailwindVersion,
})
filesUpdated.push(
path.relative(
workspaceRoot,
mainTargetConfig.resolvedPaths.tailwindConfig
)
)
}
const workspaceRoot = findCommonRoot(
// 2. Update css vars.
if (tree.cssVars) {
const overwriteCssVars = await shouldOverwriteCssVars(components, config)
await updateCssVars(tree.cssVars, mainTargetConfig, {
silent: true,
tailwindVersion,
tailwindConfig: tree.tailwind?.config,
overwriteCssVars,
})
filesUpdated.push(
path.relative(workspaceRoot, mainTargetConfig.resolvedPaths.tailwindCss)
)
}
// 3. Update CSS
if (tree.css) {
await updateCss(tree.css, mainTargetConfig, {
silent: true,
})
filesUpdated.push(
path.relative(workspaceRoot, mainTargetConfig.resolvedPaths.tailwindCss)
)
}
// 4. Update environment variables
if (tree.envVars) {
await updateEnvVars(tree.envVars, mainTargetConfig, {
silent: true,
})
}
// 5. Update dependencies.
await updateDependencies(
tree.dependencies,
tree.devDependencies,
mainTargetConfig,
{
silent: true,
}
)
// 6. Group files by their type to determine target config and update files.
const filesByType = new Map<string, typeof tree.files>()
for (const file of tree.files ?? []) {
const type = file.type || "registry:ui"
if (!filesByType.has(type)) {
filesByType.set(type, [])
}
filesByType.get(type)!.push(file)
}
// Process each type of component with its appropriate target config.
for (const type of Array.from(filesByType.keys())) {
const typeFiles = filesByType.get(type)!
let targetConfig = type === "registry:ui" ? workspaceConfig.ui : config
const typeWorkspaceRoot = findCommonRoot(
config.resolvedPaths.cwd,
targetConfig.resolvedPaths.ui
targetConfig.resolvedPaths.ui || targetConfig.resolvedPaths.cwd
)
const packageRoot =
(await findPackageRoot(workspaceRoot, targetConfig.resolvedPaths.cwd)) ??
targetConfig.resolvedPaths.cwd
(await findPackageRoot(
typeWorkspaceRoot,
targetConfig.resolvedPaths.cwd
)) ?? targetConfig.resolvedPaths.cwd
// 1. Update tailwind config.
if (component.tailwind?.config) {
await updateTailwindConfig(component.tailwind?.config, targetConfig, {
silent: true,
tailwindVersion,
})
filesUpdated.push(
path.relative(workspaceRoot, targetConfig.resolvedPaths.tailwindConfig)
)
}
// 2. Update css vars.
if (component.cssVars) {
const overwriteCssVars = await shouldOverwriteCssVars(components, config)
await updateCssVars(component.cssVars, targetConfig, {
silent: true,
tailwindVersion,
tailwindConfig: component.tailwind?.config,
overwriteCssVars,
})
filesUpdated.push(
path.relative(workspaceRoot, targetConfig.resolvedPaths.tailwindCss)
)
}
// 3. Update CSS
if (component.css) {
await updateCss(component.css, targetConfig, {
silent: true,
})
filesUpdated.push(
path.relative(workspaceRoot, targetConfig.resolvedPaths.tailwindCss)
)
}
// 4. Update environment variables
if (component.envVars) {
await updateEnvVars(component.envVars, targetConfig, {
silent: true,
})
}
// 5. Update dependencies.
await updateDependencies(
component.dependencies,
component.devDependencies,
targetConfig,
{
silent: true,
}
)
// 6. Update files.
const files = await updateFiles(component.files, targetConfig, {
// Update files for this type.
const files = await updateFiles(typeFiles, targetConfig, {
overwrite: options.overwrite,
silent: true,
rootSpinner,
isRemote: options.isRemote,
isWorkspace: true,
})
filesCreated.push(
...files.filesCreated.map((file) =>
path.relative(workspaceRoot, path.join(packageRoot, file))
path.relative(typeWorkspaceRoot, path.join(packageRoot, file))
)
)
filesUpdated.push(
...files.filesUpdated.map((file) =>
path.relative(workspaceRoot, path.join(packageRoot, file))
path.relative(typeWorkspaceRoot, path.join(packageRoot, file))
)
)
filesSkipped.push(
...files.filesSkipped.map((file) =>
path.relative(workspaceRoot, path.join(packageRoot, file))
path.relative(typeWorkspaceRoot, path.join(packageRoot, file))
)
)
}
@@ -343,6 +347,10 @@ async function addWorkspaceComponents(
logger.log(` - ${file}`)
}
}
if (tree.docs) {
logger.info(tree.docs)
}
}
async function shouldOverwriteCssVars(

View File

@@ -0,0 +1,260 @@
import { describe, expect, it } from "vitest"
import { isContentSame } from "./compare"
describe("isContentSame", () => {
describe("basic comparisons", () => {
it("should return true for identical content", () => {
const content = `const foo = "bar"`
expect(isContentSame(content, content)).toBe(true)
})
it("should return true for content with different line endings", () => {
const content1 = `line1\nline2\nline3`
const content2 = `line1\r\nline2\r\nline3`
expect(isContentSame(content1, content2)).toBe(true)
})
it("should return true for content with different whitespace at ends", () => {
const content1 = ` const foo = "bar" `
const content2 = `const foo = "bar"`
expect(isContentSame(content1, content2)).toBe(true)
})
it("should return false for different content", () => {
const content1 = `const foo = "bar"`
const content2 = `const foo = "baz"`
expect(isContentSame(content1, content2)).toBe(false)
})
})
describe("import comparisons with ignoreImports enabled", () => {
it("should return true for different aliased imports to same module", () => {
const content1 = `import { Button } from "@/components/ui/button"`
const content2 = `import { Button } from "~/ui/button"`
expect(isContentSame(content1, content2, { ignoreImports: true })).toBe(
true
)
})
it("should return true for different paths to same final module", () => {
const content1 = `import { cn } from "@/lib/utils"`
const content2 = `import { cn } from "~/utils"`
expect(isContentSame(content1, content2, { ignoreImports: true })).toBe(
true
)
})
it("should preserve relative imports and require exact match", () => {
const content1 = `import { Button } from "./button"`
const content2 = `import { Button } from "../button"`
expect(isContentSame(content1, content2, { ignoreImports: true })).toBe(
false
)
})
it("should handle multiple imports with different aliases", () => {
const content1 = `
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { Card } from "@/components/ui/card"
export function Component() {
return <div />
}
`
const content2 = `
import { Button } from "~/ui/button"
import { cn } from "~/utils"
import { Card } from "#/components/card"
export function Component() {
return <div />
}
`
expect(isContentSame(content1, content2, { ignoreImports: true })).toBe(
true
)
})
it("should handle type imports", () => {
const content1 = `import type { Config } from "@/types/config"`
const content2 = `import type { Config } from "~/config"`
expect(isContentSame(content1, content2, { ignoreImports: true })).toBe(
true
)
})
it("should handle namespace imports", () => {
const content1 = `import * as React from "react"`
const content2 = `import * as React from "react"`
expect(isContentSame(content1, content2)).toBe(true)
})
it("should handle mixed default and named imports", () => {
const content1 = `import React, { useState } from "react"`
const content2 = `import React, { useState } from "react"`
expect(isContentSame(content1, content2)).toBe(true)
})
it("should return false if non-import content differs", () => {
const content1 = `
import { Button } from "@/components/ui/button"
export const foo = "bar"
`
const content2 = `
import { Button } from "~/ui/button"
export const foo = "baz"
`
expect(isContentSame(content1, content2, { ignoreImports: true })).toBe(
false
)
})
it("should handle imports with renamed exports", () => {
const content1 = `import { Button as Btn } from "@/components/ui/button"`
const content2 = `import { Button as Btn } from "~/ui/button"`
expect(isContentSame(content1, content2, { ignoreImports: true })).toBe(
true
)
})
it("should handle multiline imports", () => {
const content1 = `import {
Button,
ButtonProps,
ButtonVariants
} from "@/components/ui/button"`
const content2 = `import {
Button,
ButtonProps,
ButtonVariants
} from "~/ui/button"`
expect(isContentSame(content1, content2, { ignoreImports: true })).toBe(
true
)
})
})
describe("import comparisons with ignoreImports disabled (default)", () => {
it("should return false for different aliased imports", () => {
const content1 = `import { Button } from "@/components/ui/button"`
const content2 = `import { Button } from "~/ui/button"`
expect(isContentSame(content1, content2, { ignoreImports: false })).toBe(
false
)
})
it("should return false for different aliased imports by default", () => {
const content1 = `import { Button } from "@/components/ui/button"`
const content2 = `import { Button } from "~/ui/button"`
expect(isContentSame(content1, content2)).toBe(false)
})
it("should return true only for exact matches", () => {
const content1 = `import { Button } from "@/components/ui/button"`
const content2 = `import { Button } from "@/components/ui/button"`
expect(isContentSame(content1, content2, { ignoreImports: false })).toBe(
true
)
})
it("should still normalize line endings and whitespace", () => {
const content1 = `import { Button } from "@/components/ui/button"\r\n`
const content2 = `import { Button } from "@/components/ui/button"\n`
expect(isContentSame(content1, content2, { ignoreImports: false })).toBe(
true
)
})
})
describe("complex real-world scenarios", () => {
it("should handle React component with different import aliases", () => {
const component1 = `
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
export function ProfileCard({ className }: { className?: string }) {
return (
<Card className={cn("w-full", className)}>
<CardHeader>Profile</CardHeader>
<CardContent>
<Button>Click me</Button>
</CardContent>
</Card>
)
}
`
const component2 = `
import * as React from "react"
import { cn } from "~/utils"
import { Button } from "~/ui/button"
import { Card, CardContent, CardHeader } from "#/components/card"
export function ProfileCard({ className }: { className?: string }) {
return (
<Card className={cn("w-full", className)}>
<CardHeader>Profile</CardHeader>
<CardContent>
<Button>Click me</Button>
</CardContent>
</Card>
)
}
`
expect(
isContentSame(component1, component2, { ignoreImports: true })
).toBe(true)
})
it("should detect actual code differences", () => {
const component1 = `
import { Button } from "@/components/ui/button"
export function Component() {
return <Button variant="default">Click</Button>
}
`
const component2 = `
import { Button } from "~/ui/button"
export function Component() {
return <Button variant="outline">Click</Button>
}
`
expect(
isContentSame(component1, component2, { ignoreImports: true })
).toBe(false)
})
it("should handle files with no imports", () => {
const content1 = `
export function add(a: number, b: number) {
return a + b
}
`
const content2 = `
export function add(a: number, b: number) {
return a + b
}
`
expect(isContentSame(content1, content2)).toBe(true)
})
it("should handle CSS imports", () => {
const content1 = `
import styles from "@/styles/component.module.css"
import "./global.css"
`
const content2 = `
import styles from "~/styles/component.module.css"
import "./global.css"
`
expect(isContentSame(content1, content2, { ignoreImports: true })).toBe(
true
)
})
})
})

View File

@@ -0,0 +1,61 @@
export function isContentSame(
existingContent: string,
newContent: string,
options: {
ignoreImports?: boolean
} = {}
) {
const { ignoreImports = false } = options
// Normalize line endings and whitespace.
const normalizedExisting = existingContent.replace(/\r\n/g, "\n").trim()
const normalizedNew = newContent.replace(/\r\n/g, "\n").trim()
// First, try exact match after normalization.
if (normalizedExisting === normalizedNew) {
return true
}
// If not ignoring imports or exact match failed, return false
if (!ignoreImports) {
return false
}
// Compare with import statements normalized.
// This regex matches various import patterns including:
// - import defaultExport from "module"
// - import * as name from "module"
// - import { export1, export2 } from "module"
// - import { export1 as alias1 } from "module"
// - import defaultExport, { export1 } from "module"
// - import type { Type } from "module"
// - This Regex written by Claude Code.
const importRegex =
/^(import\s+(?:type\s+)?(?:\*\s+as\s+\w+|\{[^}]*\}|\w+)?(?:\s*,\s*(?:\{[^}]*\}|\w+))?\s+from\s+["'])([^"']+)(["'])/gm
// Function to normalize import paths - remove alias differences.
const normalizeImports = (content: string) => {
return content.replace(
importRegex,
(_match, prefix, importPath, suffix) => {
// Keep relative imports as-is.
if (importPath.startsWith(".")) {
return `${prefix}${importPath}${suffix}`
}
// For aliased imports, normalize to a common format.
// Extract the last meaningful part of the path.
const parts = importPath.split("/")
const lastPart = parts[parts.length - 1]
// Normalize to a consistent format.
return `${prefix}@normalized/${lastPart}${suffix}`
}
)
}
const existingNormalized = normalizeImports(normalizedExisting)
const newNormalized = normalizeImports(normalizedNew)
return existingNormalized === newNormalized
}

View File

@@ -3,6 +3,7 @@ import { tmpdir } from "os"
import path, { basename } from "path"
import { getRegistryBaseColor } from "@/src/registry/api"
import { RegistryItem, registryItemFileSchema } from "@/src/registry/schema"
import { isContentSame } from "@/src/utils/compare"
import {
findExistingEnvFile,
getNewEnvKeys,
@@ -36,6 +37,7 @@ export async function updateFiles(
silent?: boolean
rootSpinner?: ReturnType<typeof spinner>
isRemote?: boolean
isWorkspace?: boolean
}
) {
if (!files?.length) {
@@ -50,6 +52,7 @@ export async function updateFiles(
force: false,
silent: false,
isRemote: false,
isWorkspace: false,
...options,
}
const filesCreatedSpinner = spinner(`Updating files.`, {
@@ -131,11 +134,14 @@ export async function updateFiles(
// Exception: Don't skip .env files as we merge content instead of replacing
if (existingFile && !isEnvFile(filePath)) {
const existingFileContent = await fs.readFile(filePath, "utf-8")
const [normalizedExisting, normalizedNew] = await Promise.all([
getNormalizedFileContent(existingFileContent),
getNormalizedFileContent(content),
])
if (normalizedExisting === normalizedNew) {
if (
isContentSame(existingFileContent, content, {
// Ignore import differences for workspace components.
// TODO: figure out if we always want this.
ignoreImports: options.isWorkspace,
})
) {
filesSkipped.push(path.relative(config.resolvedPaths.cwd, filePath))
continue
}
@@ -410,10 +416,6 @@ export function resolveNestedFilePath(
return fileSegments.slice(commonDirIndex + 1).join("/")
}
export async function getNormalizedFileContent(content: string) {
return content.replace(/\r\n/g, "\n").trim()
}
export function resolvePageTarget(
target: string,
framework?: ProjectInfo["framework"]["name"]