From a80ab374830fa414bf3672fefc66b03ea4b8da78 Mon Sep 17 00:00:00 2001 From: shadcn Date: Wed, 6 Aug 2025 15:13:51 +0400 Subject: [PATCH] feat(shadcn): update file handling for monorepo (#7955) * feat(shadcn): update monorepo handling * feat(shadcn): update file handling for monorepo * chore: changeset --- .changeset/hungry-melons-press.md | 5 + packages/shadcn/src/utils/add-components.ts | 208 +++++++------- packages/shadcn/src/utils/compare.test.ts | 260 ++++++++++++++++++ packages/shadcn/src/utils/compare.ts | 61 ++++ .../shadcn/src/utils/updaters/update-files.ts | 20 +- 5 files changed, 445 insertions(+), 109 deletions(-) create mode 100644 .changeset/hungry-melons-press.md create mode 100644 packages/shadcn/src/utils/compare.test.ts create mode 100644 packages/shadcn/src/utils/compare.ts diff --git a/.changeset/hungry-melons-press.md b/.changeset/hungry-melons-press.md new file mode 100644 index 000000000..b7dc66c30 --- /dev/null +++ b/.changeset/hungry-melons-press.md @@ -0,0 +1,5 @@ +--- +"shadcn": minor +--- + +update file handling for monorepo diff --git a/packages/shadcn/src/utils/add-components.ts b/packages/shadcn/src/utils/add-components.ts index ca0084530..cfca7623d 100644 --- a/packages/shadcn/src/utils/add-components.ts +++ b/packages/shadcn/src/utils/add-components.ts @@ -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() + + 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( diff --git a/packages/shadcn/src/utils/compare.test.ts b/packages/shadcn/src/utils/compare.test.ts new file mode 100644 index 000000000..ac5d6227a --- /dev/null +++ b/packages/shadcn/src/utils/compare.test.ts @@ -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
+} +` + const content2 = ` +import { Button } from "~/ui/button" +import { cn } from "~/utils" +import { Card } from "#/components/card" + +export function Component() { + return
+} +` + 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 ( + + Profile + + + + + ) +} +` + 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 ( + + Profile + + + + + ) +} +` + 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 +} +` + const component2 = ` +import { Button } from "~/ui/button" + +export function Component() { + return +} +` + 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 + ) + }) + }) +}) diff --git a/packages/shadcn/src/utils/compare.ts b/packages/shadcn/src/utils/compare.ts new file mode 100644 index 000000000..5bacd5936 --- /dev/null +++ b/packages/shadcn/src/utils/compare.ts @@ -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 +} diff --git a/packages/shadcn/src/utils/updaters/update-files.ts b/packages/shadcn/src/utils/updaters/update-files.ts index e05f8dd10..b46d89ed2 100644 --- a/packages/shadcn/src/utils/updaters/update-files.ts +++ b/packages/shadcn/src/utils/updaters/update-files.ts @@ -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 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"]