This commit is contained in:
shadcn
2026-02-20 16:40:41 +04:00
parent b7ced9f289
commit 76ba624dce
25 changed files with 1267 additions and 181 deletions

View File

@@ -129,13 +129,13 @@ function buildGlobalsCss(registryBase: RegistryItem) {
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {

View File

@@ -74,10 +74,13 @@ Add the following to your styles/globals.css file. You can learn more about usin
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);

View File

@@ -199,6 +199,7 @@ export const create = new Command()
initUrl = resolveInitUrl(
{
...decoded,
base: "radix",
rtl: opts.rtl ?? false,
},
{ template }

View File

@@ -58,12 +58,14 @@ export const initOptionsSchema = z.object({
yes: z.boolean(),
defaults: z.boolean(),
force: z.boolean(),
reinstall: z.boolean(),
reinstall: z.boolean().optional(),
silent: z.boolean(),
isNewProject: z.boolean().default(false),
cssVariables: z.boolean().default(true),
rtl: z.boolean().optional(),
base: z.enum(["radix", "base"]).optional(),
template: z.string().optional(),
existingConfig: z.record(z.unknown()).optional(),
installStyleIndex: z.boolean().default(true),
registryBaseConfig: rawConfigSchema.deepPartial().optional(),
})
@@ -77,6 +79,7 @@ export const init = new Command()
"-t, --template <template>",
"the template to use. (next, start, vite, next-monorepo, react-router)"
)
.option("-b, --base <base>", "the primitive library to use. (radix, base)")
.option("-p, --preset [name]", "use a preset configuration")
.option("-y, --yes", "skip confirmation prompt.", true)
.option(
@@ -96,14 +99,26 @@ export const init = new Command()
.option("--no-css-variables", "do not use css variables for theming.")
.option("--rtl", "enable RTL support.")
.option("--no-rtl", "disable RTL support.")
.option("--reinstall", "re-install existing UI components.", false)
.option("--reinstall", "re-install existing UI components.")
.option("--no-reinstall", "do not re-install existing UI components.")
.action(async (components, opts) => {
let componentsJsonBackupPath: string | undefined
let reinstallComponents: string[] = []
// Restore components.json backup on unexpected exit (e.g. process.exit in preflight).
const restoreBackupOnExit = () => {
if (componentsJsonBackupPath) {
restoreFileBackup(
componentsJsonBackupPath.replace(FILE_BACKUP_SUFFIX, "")
)
}
}
process.on("exit", restoreBackupOnExit)
try {
const options = initOptionsSchema.parse({
...opts,
reinstall: opts.reinstall,
cwd: path.resolve(opts.cwd),
})
const presets = Object.values(DEFAULT_PRESETS)
@@ -111,18 +126,11 @@ export const init = new Command()
presets.map((preset) => [preset.name, preset])
)
let newBase: string | undefined
let presetBase: string | undefined
if (options.defaults) {
options.template = options.template || "next"
const initUrl = resolveInitUrl(
{
...DEFAULT_PRESETS["base-nova"],
rtl: options.rtl ?? false,
},
{ template: options.template }
)
components = [initUrl, ...components]
newBase = DEFAULT_PRESETS["base-nova"].base
// Base resolution happens below — just mark presetBase as undefined.
}
if (options.template && !(options.template in templates)) {
@@ -193,6 +201,11 @@ export const init = new Command()
// Ignore read errors.
}
// Pass existing config so preflight can use it (e.g. tailwind.css path in monorepos).
if (existingConfig) {
options.existingConfig = existingConfig
}
let shouldReinstall = options.reinstall
if (!shouldReinstall) {
@@ -275,7 +288,7 @@ export const init = new Command()
template: options.template,
})
components = [result.url, ...components]
newBase = result.base
presetBase = result.base
}
if (typeof presetArg === "string") {
@@ -289,7 +302,7 @@ export const init = new Command()
url.searchParams.delete("rtl")
}
initUrl = url.toString()
newBase = url.searchParams.get("base") ?? undefined
presetBase = url.searchParams.get("base") ?? undefined
} else if (isPresetCode(presetArg)) {
const decoded = decodePreset(presetArg)
if (!decoded) {
@@ -299,14 +312,17 @@ export const init = new Command()
logger.break()
process.exit(1)
}
// Preset codes no longer carry base — use "radix" as placeholder.
// The correct base is set in the URL after resolution below.
initUrl = resolveInitUrl(
{
...decoded,
base: "radix",
rtl: options.rtl ?? false,
},
{ template: options.template }
)
newBase = decoded.base
presetBase = undefined
} else {
const preset = presetsByName.get(presetArg)!
initUrl = resolveInitUrl(
@@ -316,23 +332,80 @@ export const init = new Command()
},
{ template: options.template }
)
newBase = preset.base
presetBase = preset.base
}
components = [initUrl, ...components]
}
}
// Resolve base: --base flag > preset/prompt/URL > existing config > prompt.
let resolvedBase: string =
options.base ??
presetBase ??
(existingConfig?.style
? (existingConfig.style as string).startsWith("base-")
? "base"
: "radix"
: "")
if (!resolvedBase) {
const { base } = await prompts({
type: "select",
name: "base",
message: `Which ${highlighter.info(
"primitive library"
)} would you like to use?`,
choices: [
{ title: "Radix", value: "radix" },
{ title: "Base", value: "base" },
],
})
if (!base) process.exit(0)
resolvedBase = base
}
// Build the --defaults URL now that base is resolved.
if (options.defaults && components.length === 0) {
const presetName = resolvedBase === "base" ? "base-nova" : "radix-nova"
const initUrl = resolveInitUrl(
{
...DEFAULT_PRESETS[presetName],
rtl: options.rtl ?? false,
},
{ template: options.template }
)
components = [initUrl, ...components]
}
// Ensure the init URL has the correct base.
if (components.length > 0 && isUrl(components[0])) {
const url = new URL(components[0])
url.searchParams.set("base", resolvedBase)
components[0] = url.toString()
}
// Confirm if the user is switching bases during reinit.
if (existingConfig?.style) {
const confirmedBase = await confirmBaseSwitch(
existingConfig.style as string,
resolvedBase
)
if (confirmedBase !== resolvedBase) {
resolvedBase = confirmedBase
if (components.length > 0 && isUrl(components[0])) {
const url = new URL(components[0])
url.searchParams.set("base", confirmedBase)
components[0] = url.toString()
}
}
}
// Add re-install components after preset selection.
if (reinstallComponents.length) {
components = [...components, ...reinstallComponents]
}
// Warn if the user is switching bases during reinit.
if (existingConfig?.style && newBase) {
warnOnBaseSwitch(existingConfig.style as string, newBase)
}
options.components = components
await loadEnvFiles(options.cwd)
@@ -378,15 +451,14 @@ export const init = new Command()
`Project initialization completed.\nYou may now add components.`
)
// We need when running with custom cwd.
// Success — remove the backup and exit listener.
process.removeListener("exit", restoreBackupOnExit)
deleteFileBackup(path.resolve(cwd, "components.json"))
logger.break()
} catch (error) {
if (componentsJsonBackupPath) {
restoreFileBackup(
componentsJsonBackupPath.replace(FILE_BACKUP_SUFFIX, "")
)
}
// Restore handled by exit listener, but also do it here for non-exit errors.
process.removeListener("exit", restoreBackupOnExit)
restoreBackupOnExit()
logger.break()
handleError(error)
} finally {
@@ -721,22 +793,31 @@ async function promptForMinimalConfig(
})
}
function warnOnBaseSwitch(existingStyle: string, newBase: string) {
async function confirmBaseSwitch(existingStyle: string, resolvedBase: string) {
// Styles prefixed with "base-" use Base UI. Everything else is Radix.
const oldBase = existingStyle.startsWith("base-") ? "base" : "radix"
if (newBase !== oldBase) {
logger.warn(
` You are switching from ${highlighter.info(
oldBase
)} to ${highlighter.info(newBase)}.`
)
logger.warn(
` Components outside the ${highlighter.info(
"ui"
)} directory that depend on ${highlighter.info(
oldBase
)} primitives may need manual updates.`
)
logger.break()
}
if (resolvedBase === oldBase) return resolvedBase
logger.warn(
` You are switching from ${highlighter.info(
oldBase
)} to ${highlighter.info(resolvedBase)}.`
)
logger.warn(
` Components outside the ${highlighter.info(
"ui"
)} directory that depend on ${highlighter.info(
oldBase
)} primitives may need manual updates.`
)
logger.break()
const { proceed } = await prompts({
type: "confirm",
name: "proceed",
message: "Would you like to continue?",
initial: false,
})
return proceed ? resolvedBase : oldBase
}

View File

@@ -54,7 +54,13 @@ export async function preFlightInit(
const frameworkSpinner = spinner(`Verifying framework.`, {
silent: options.silent,
}).start()
const projectInfo = await getProjectInfo(options.cwd)
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()

View File

@@ -20,7 +20,6 @@ import { isSafeTarget } from "@/src/utils/is-safe-target"
import { logger } from "@/src/utils/logger"
import { spinner } from "@/src/utils/spinner"
import { updateCss } from "@/src/utils/updaters/update-css"
import { updateCssVars } from "@/src/utils/updaters/update-css-vars"
import { updateDependencies } from "@/src/utils/updaters/update-dependencies"
import { updateEnvVars } from "@/src/utils/updaters/update-env-vars"
import { updateFiles } from "@/src/utils/updaters/update-files"
@@ -110,35 +109,19 @@ async function addProjectComponents(
tree = await massageTreeForFonts(tree, config)
}
await updateDependencies(tree.dependencies, tree.devDependencies, config, {
silent: options.silent,
})
await updateTailwindConfig(tree.tailwind?.config, config, {
silent: options.silent,
tailwindVersion,
})
const overwriteCssVars =
options.overwriteCssVars ??
(await shouldOverwriteCssVars(components, config))
await updateCssVars(tree.cssVars, config, {
cleanupDefaultNextStyles: options.isNewProject,
silent: options.silent,
tailwindVersion,
tailwindConfig: tree.tailwind?.config,
overwriteCssVars,
})
// Add CSS updater
await updateCss(tree.css, config, {
silent: options.silent,
})
await updateEnvVars(tree.envVars, config, {
silent: options.silent,
})
await updateDependencies(tree.dependencies, tree.devDependencies, config, {
silent: options.silent,
})
if (!options.skipFonts) {
await updateFonts(tree.fonts, config, {
silent: options.silent,
@@ -151,6 +134,21 @@ async function addProjectComponents(
path: options.path,
})
// Write CSS last so the file watcher triggers a rebuild
// after all component files and dependencies are in place.
const overwriteCssVars = tree.cssVars
? (options.overwriteCssVars ??
(await shouldOverwriteCssVars(components, config)))
: undefined
await updateCss(tree.css, config, {
silent: options.silent,
cssVars: tree.cssVars,
cleanupDefaultNextStyles: options.isNewProject,
overwriteCssVars,
tailwindVersion,
tailwindConfig: tree.tailwind?.config,
})
if (tree.docs) {
logger.info(tree.docs)
}
@@ -197,7 +195,7 @@ async function addWorkspaceComponents(
const rootSpinner = spinner(`Installing components.`)?.start()
// Process global updates (tailwind, css vars, dependencies) first for the main target.
// Process global updates for the main target.
// These should typically go to the UI package in a workspace.
const mainTargetConfig = workspaceConfig.ui
const tailwindVersion = await getProjectTailwindVersionFromConfig(
@@ -208,7 +206,17 @@ async function addWorkspaceComponents(
mainTargetConfig.resolvedPaths.ui
)
// 1. Update tailwind config.
// 1. Update dependencies.
await updateDependencies(
tree.dependencies,
tree.devDependencies,
mainTargetConfig,
{
silent: true,
}
)
// 2. Update tailwind config.
if (tree.tailwind?.config) {
await updateTailwindConfig(tree.tailwind?.config, mainTargetConfig, {
silent: true,
@@ -222,55 +230,21 @@ async function addWorkspaceComponents(
)
}
// 2. Update css vars.
if (tree.cssVars) {
const overwriteCssVars =
options.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
// 3. 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. Update fonts.
await updateFonts(tree.fonts, mainTargetConfig, {
// 4. Update fonts.
// Fonts modify the app's layout file (e.g. app/layout.tsx),
// so we use the app config, not the UI workspace config.
await updateFonts(tree.fonts, config, {
silent: true,
})
// 7. Group files by their type to determine target config and update files.
// 5. 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 ?? []) {
@@ -281,11 +255,19 @@ async function addWorkspaceComponents(
filesByType.get(type)!.push(file)
}
const FILE_TYPE_TO_CONFIG_KEY: Record<string, string> = {
"registry:ui": "ui",
"registry:hook": "hooks",
"registry:lib": "lib",
}
// 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 configKey = FILE_TYPE_TO_CONFIG_KEY[type]
const targetConfig =
configKey && workspaceConfig[configKey] ? workspaceConfig[configKey] : config
const typeWorkspaceRoot = findCommonRoot(
config.resolvedPaths.cwd,
@@ -324,6 +306,25 @@ async function addWorkspaceComponents(
)
}
// 6. Write CSS last so the file watcher triggers a rebuild
// after all component files and dependencies are in place.
const overwriteCssVars = tree.cssVars
? (options.overwriteCssVars ??
(await shouldOverwriteCssVars(components, config)))
: undefined
await updateCss(tree.css, mainTargetConfig, {
silent: true,
cssVars: tree.cssVars,
overwriteCssVars,
tailwindVersion,
tailwindConfig: tree.tailwind?.config,
})
if (tree.cssVars || tree.css) {
filesUpdated.push(
path.relative(workspaceRoot, mainTargetConfig.resolvedPaths.tailwindCss)
)
}
rootSpinner?.succeed()
// Deduplicate and sort files.

View File

@@ -38,7 +38,10 @@ const TS_CONFIG_SCHEMA = z.object({
}),
})
export async function getProjectInfo(cwd: string): Promise<ProjectInfo | null> {
export async function getProjectInfo(
cwd: string,
opts?: { configCssFile?: string }
): Promise<ProjectInfo | null> {
const [
configFiles,
isSrcDir,
@@ -60,7 +63,7 @@ export async function getProjectInfo(cwd: string): Promise<ProjectInfo | null> {
fs.pathExists(path.resolve(cwd, "src")),
isTypeScriptProject(cwd),
getTailwindConfigFile(cwd),
getTailwindCssFile(cwd),
getTailwindCssFile(cwd, opts?.configCssFile),
getTailwindVersion(cwd),
getTsConfigAliasPrefix(cwd),
getPackageInfo(cwd, false),
@@ -242,7 +245,18 @@ export async function getTailwindVersion(
return "v4"
}
export async function getTailwindCssFile(cwd: string) {
export async function getTailwindCssFile(
cwd: string,
configCssFile?: string
) {
// If the existing config has a known CSS file, check it first.
if (configCssFile) {
const resolvedPath = path.resolve(cwd, configCssFile)
if (await fs.pathExists(resolvedPath)) {
return configCssFile
}
}
const [files, tailwindVersion] = await Promise.all([
fg.glob(["**/*.css", "**/*.scss"], {
cwd,

View File

@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"
import {
DEFAULT_PRESET_CONFIG,
PRESET_BASES,
PRESET_BASE_COLORS,
PRESET_FONTS,
PRESET_ICON_LIBRARIES,
@@ -46,7 +45,6 @@ describe("encodePreset / decodePreset", () => {
it("should round-trip a custom config", () => {
const config: PresetConfig = {
base: "base",
style: "lyra",
baseColor: "zinc",
theme: "blue",
@@ -72,22 +70,19 @@ describe("encodePreset / decodePreset", () => {
})
it("should handle partial config by filling defaults", () => {
const code = encodePreset({ base: "base" })
const code = encodePreset({ style: "lyra" })
const decoded = decodePreset(code)
expect(decoded).not.toBeNull()
expect(decoded!.base).toBe("base")
expect(decoded!.style).toBe(DEFAULT_PRESET_CONFIG.style)
expect(decoded!.style).toBe("lyra")
expect(decoded!.theme).toBe(DEFAULT_PRESET_CONFIG.theme)
})
it("should round-trip all combinations of base and style", () => {
for (const base of PRESET_BASES) {
for (const style of PRESET_STYLES) {
const code = encodePreset({ base, style })
const decoded = decodePreset(code)
expect(decoded).not.toBeNull()
expect(decoded!.base).toBe(base)
expect(decoded!.style).toBe(style)
}
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)
}
})

View File

@@ -67,7 +67,9 @@ export const PRESET_RADII = [
export const PRESET_MENU_ACCENTS = ["subtle", "bold"] as const
export const PRESET_MENU_COLORS = ["default", "inverted"] as const
// Field definitions in pack order. Total: 43 bits, 10 bits headroom.
// Field definitions in pack order. Total: 40 bits, 13 bits headroom.
// Note: `base` was removed (was bits 40-42). Old codes are backward-compatible
// because `base` was the last field — decoder stops at bit 40 and ignores the rest.
const PRESET_FIELDS = [
{ key: "menuColor", values: PRESET_MENU_COLORS, bits: 3 },
{ key: "menuAccent", values: PRESET_MENU_ACCENTS, bits: 3 },
@@ -77,11 +79,9 @@ const PRESET_FIELDS = [
{ key: "theme", values: PRESET_THEMES, bits: 6 },
{ key: "baseColor", values: PRESET_BASE_COLORS, bits: 6 },
{ key: "style", values: PRESET_STYLES, bits: 6 },
{ key: "base", values: PRESET_BASES, bits: 3 },
] as const
export type PresetConfig = {
base: (typeof PRESET_BASES)[number]
style: (typeof PRESET_STYLES)[number]
baseColor: (typeof PRESET_BASE_COLORS)[number]
theme: (typeof PRESET_THEMES)[number]

View File

@@ -55,9 +55,6 @@ export async function updateCssVars(
overwriteCssVars: options.overwriteCssVars,
})
await fs.writeFile(cssFilepath, output, "utf8")
// Touch the file to ensure dev server file watchers detect the change.
const now = new Date()
await fs.utimes(cssFilepath, now, now)
cssVarsSpinner.succeed()
}
@@ -433,13 +430,13 @@ function updateThemePlugin(cssVars: z.infer<typeof registryItemCssVarsSchema>) {
if (variable === "radius") {
const radiusVariables = {
sm: "calc(var(--radius) - 4px)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) * 0.6)",
md: "calc(var(--radius) * 0.8)",
lg: "var(--radius)",
xl: "calc(var(--radius) + 4px)",
"2xl": "calc(var(--radius) + 8px)",
"3xl": "calc(var(--radius) + 12px)",
"4xl": "calc(var(--radius) + 16px)",
xl: "calc(var(--radius) * 1.4)",
"2xl": "calc(var(--radius) * 1.8)",
"3xl": "calc(var(--radius) * 2.2)",
"4xl": "calc(var(--radius) * 2.6)",
}
for (const [key, value] of Object.entries(radiusVariables)) {
const cssVarNode = postcss.decl({

View File

@@ -1,9 +1,15 @@
import { promises as fs } from "fs"
import path from "path"
import { registryItemCssSchema } from "@/src/schema"
import {
registryItemCssSchema,
registryItemCssVarsSchema,
registryItemTailwindSchema,
} from "@/src/schema"
import { Config } from "@/src/utils/get-config"
import { TailwindVersion } from "@/src/utils/get-project-info"
import { highlighter } from "@/src/utils/highlighter"
import { spinner } from "@/src/utils/spinner"
import { transformCssVars } from "@/src/utils/updaters/update-css-vars"
import postcss from "postcss"
import AtRule from "postcss/lib/at-rule"
import Declaration from "postcss/lib/declaration"
@@ -16,13 +22,17 @@ export async function updateCss(
config: Config,
options: {
silent?: boolean
cssVars?: z.infer<typeof registryItemCssVarsSchema>
cleanupDefaultNextStyles?: boolean
overwriteCssVars?: boolean
tailwindVersion?: TailwindVersion
tailwindConfig?: z.infer<typeof registryItemTailwindSchema>["config"]
}
) {
if (
!config.resolvedPaths.tailwindCss ||
!css ||
Object.keys(css).length === 0
) {
const hasCss = css && Object.keys(css).length > 0
const hasCssVars = Object.keys(options.cssVars ?? {}).length > 0
if (!config.resolvedPaths.tailwindCss || (!hasCss && !hasCssVars)) {
return
}
@@ -43,8 +53,23 @@ export async function updateCss(
}
).start()
const raw = await fs.readFile(cssFilepath, "utf8")
let output = await transformCss(raw, css)
let output = await fs.readFile(cssFilepath, "utf8")
// Apply CSS vars transform first if provided.
if (hasCssVars) {
output = await transformCssVars(output, options.cssVars!, config, {
cleanupDefaultNextStyles: options.cleanupDefaultNextStyles,
tailwindVersion: options.tailwindVersion,
tailwindConfig: options.tailwindConfig,
overwriteCssVars: options.overwriteCssVars,
})
}
// Apply CSS transform if provided.
if (hasCss) {
output = await transformCss(output, css!)
}
await fs.writeFile(cssFilepath, output, "utf8")
cssSpinner.succeed()
}

View File

@@ -237,17 +237,11 @@ export async function updateFiles(
}
await fs.writeFile(filePath, mergedContent, "utf-8")
// Touch the file to ensure dev server file watchers detect the change.
const now = new Date()
await fs.utimes(filePath, now, now)
filesUpdated.push(path.relative(config.resolvedPaths.cwd, filePath))
continue
}
await fs.writeFile(filePath, content, "utf-8")
// Touch the file to ensure dev server file watchers detect the change.
const now = new Date()
await fs.utimes(filePath, now, now)
// Handle file creation logging
if (!existingFile) {
@@ -611,9 +605,6 @@ async function resolveImports(filePaths: string[], config: Config) {
// Write the updated content to the file.
await fs.writeFile(resolvedPath, sourceFile.getFullText(), "utf-8")
// Touch the file to ensure dev server file watchers detect the change.
const now = new Date()
await fs.utimes(resolvedPath, now, now)
// Track the updated file.
updatedFiles.push(filepath)

View File

@@ -167,6 +167,124 @@ export default function RootLayout({
`)
})
it("should use configured utils alias when adding cn import", async () => {
const configWithCustomUtilsAlias = {
...mockConfig,
aliases: {
...mockConfig.aliases,
utils: "~/lib/utils",
},
}
const input = `
import type { Metadata } from "next"
import "./globals.css"
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
`
const fonts = [
{
name: "inter",
type: "registry:font" as const,
font: {
family: "Inter",
provider: "google" as const,
import: "Inter",
variable: "--font-sans",
subsets: ["latin"],
},
},
{
name: "jetbrains-mono",
type: "registry:font" as const,
font: {
family: "JetBrains Mono",
provider: "google" as const,
import: "JetBrains_Mono",
variable: "--font-mono",
subsets: ["latin"],
},
},
]
const firstRun = await transformLayoutFonts(
input,
fonts,
configWithCustomUtilsAlias
)
const secondRun = await transformLayoutFonts(
firstRun,
fonts,
configWithCustomUtilsAlias
)
expect(firstRun).toContain(`import { cn } from "~/lib/utils";`)
expect(secondRun).toBe(firstRun)
})
it("should use monorepo utils alias when adding cn import", async () => {
const monorepoConfig = {
...mockConfig,
aliases: {
...mockConfig.aliases,
utils: "@workspace/ui/lib/utils",
},
}
const input = `
import type { Metadata } from "next"
import "./globals.css"
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
`
const fonts = [
{
name: "inter",
type: "registry:font" as const,
font: {
family: "Inter",
provider: "google" as const,
import: "Inter",
variable: "--font-sans",
subsets: ["latin"],
},
},
{
name: "jetbrains-mono",
type: "registry:font" as const,
font: {
family: "JetBrains Mono",
provider: "google" as const,
import: "JetBrains_Mono",
variable: "--font-mono",
subsets: ["latin"],
},
},
]
const result = await transformLayoutFonts(input, fonts, monorepoConfig)
expect(result).toContain(`import { cn } from "@workspace/ui/lib/utils";`)
})
it("should preserve existing string className", async () => {
const input = `
import type { Metadata } from "next"

View File

@@ -162,7 +162,7 @@ async function findLayoutFile(
export async function transformLayoutFonts(
input: string,
fonts: RegistryFontItem[],
_config: Config
config: Config
) {
const project = new Project({
compilerOptions: {},
@@ -254,7 +254,7 @@ export async function transformLayoutFonts(
// Update html className to include font variables.
if (fontVariableNames.length > 0) {
updateHtmlClassName(sourceFile, fontVariableNames)
updateHtmlClassName(sourceFile, fontVariableNames, config)
}
return sourceFile.getFullText()
@@ -335,7 +335,8 @@ function findInsertPosition(
function updateHtmlClassName(
sourceFile: ReturnType<Project["createSourceFile"]>,
fontVariableNames: string[]
fontVariableNames: string[],
config: Config
) {
// Find the <html> JSX element.
const jsxElements = sourceFile.getDescendantsOfKind(
@@ -360,7 +361,7 @@ function updateHtmlClassName(
})
} else {
// Need to use cn() for multiple fonts.
ensureCnImport(sourceFile)
ensureCnImport(sourceFile, config)
element.addAttribute({
name: "className",
initializer: `{cn(${variableExpressions})}`,
@@ -387,7 +388,7 @@ function updateHtmlClassName(
if (initializer.getKind() === SyntaxKind.StringLiteral) {
// className="some-class" -> className={cn("some-class", font.variable)}
const currentValue = initializer.getText().slice(1, -1) // Remove quotes.
ensureCnImport(sourceFile)
ensureCnImport(sourceFile, config)
jsxAttr.setInitializer(
`{cn("${currentValue}", ${newVarExpressions.join(", ")})}`
)
@@ -428,19 +429,19 @@ function updateHtmlClassName(
if (newVarExpressions.length === 1) {
jsxExpr.replaceWithText(`{${newVarExpressions[0]}}`)
} else {
ensureCnImport(sourceFile)
ensureCnImport(sourceFile, config)
jsxExpr.replaceWithText(`{cn(${newVarExpressions.join(", ")})}`)
}
} else if (exprText.startsWith("`") && exprText.endsWith("`")) {
// Template literal - parse and convert to cn() arguments.
const cnArgs = parseTemplateLiteralToCnArgs(exprText)
ensureCnImport(sourceFile)
ensureCnImport(sourceFile, config)
jsxExpr.replaceWithText(
`{cn(${[...cnArgs, ...newVarExpressions].join(", ")})}`
)
} else {
// Some other expression (variable, etc.), wrap with cn().
ensureCnImport(sourceFile)
ensureCnImport(sourceFile, config)
jsxExpr.replaceWithText(
`{cn(${exprText}, ${newVarExpressions.join(", ")})}`
)
@@ -449,7 +450,10 @@ function updateHtmlClassName(
}
}
function ensureCnImport(sourceFile: ReturnType<Project["createSourceFile"]>) {
function ensureCnImport(
sourceFile: ReturnType<Project["createSourceFile"]>,
config: Config
) {
const existingImport = sourceFile.getImportDeclaration((decl) => {
const namedImports = decl.getNamedImports()
return namedImports.some((imp) => imp.getName() === "cn")
@@ -470,7 +474,7 @@ function ensureCnImport(sourceFile: ReturnType<Project["createSourceFile"]>) {
} else {
// Add a new import for cn.
sourceFile.addImportDeclaration({
moduleSpecifier: "@/lib/utils",
moduleSpecifier: config.aliases.utils,
namedImports: ["cn"],
})
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div>Hello</div>
}

View File

@@ -0,0 +1,6 @@
{
"name": "next-monorepo-web",
"dependencies": {
"next": "15.0.0"
}
}

View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -0,0 +1,780 @@
import { afterEach, describe, expect, test, vi } from "vitest"
import type { Config } from "../../src/utils/get-config"
// Mock all external dependencies.
vi.mock("../../src/registry/resolver", () => ({
resolveRegistryTree: vi.fn(),
}))
vi.mock("../../src/utils/get-config", async () => {
const actual = (await vi.importActual(
"../../src/utils/get-config"
)) as typeof import("../../src/utils/get-config")
return {
...actual,
getWorkspaceConfig: vi.fn(),
findPackageRoot: vi.fn(),
}
})
vi.mock("../../src/utils/updaters/update-files", () => ({
updateFiles: vi.fn().mockResolvedValue({
filesCreated: [],
filesUpdated: [],
filesSkipped: [],
}),
}))
vi.mock("../../src/utils/updaters/update-dependencies", () => ({
updateDependencies: vi.fn().mockResolvedValue(undefined),
}))
vi.mock("../../src/utils/updaters/update-tailwind-config", () => ({
updateTailwindConfig: vi.fn().mockResolvedValue(undefined),
}))
vi.mock("../../src/utils/updaters/update-env-vars", () => ({
updateEnvVars: vi.fn().mockResolvedValue(undefined),
}))
vi.mock("../../src/utils/updaters/update-fonts", () => ({
updateFonts: vi.fn().mockResolvedValue(undefined),
massageTreeForFonts: vi.fn().mockImplementation((tree) => tree),
}))
vi.mock("../../src/utils/updaters/update-css", () => ({
updateCss: vi.fn().mockResolvedValue(undefined),
}))
vi.mock("../../src/utils/get-project-info", () => ({
getProjectTailwindVersionFromConfig: vi.fn().mockResolvedValue("4"),
}))
vi.mock("../../src/utils/spinner", () => ({
spinner: vi.fn().mockReturnValue({
start: vi.fn().mockReturnThis(),
succeed: vi.fn().mockReturnThis(),
fail: vi.fn().mockReturnThis(),
info: vi.fn().mockReturnThis(),
}),
}))
vi.mock("../../src/utils/logger", () => ({
logger: {
info: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}))
import { addComponents } from "../../src/utils/add-components"
import { resolveRegistryTree } from "../../src/registry/resolver"
import {
findPackageRoot,
getWorkspaceConfig,
} from "../../src/utils/get-config"
import { updateFiles } from "../../src/utils/updaters/update-files"
import { updateFonts } from "../../src/utils/updaters/update-fonts"
afterEach(() => {
vi.clearAllMocks()
})
function createMockConfig(overrides: Partial<Config> = {}): Config {
return {
$schema: "https://ui.shadcn.com/schema.json",
style: "new-york",
rsc: true,
tsx: true,
tailwind: {
css: "app/globals.css",
baseColor: "zinc",
cssVariables: true,
},
aliases: {
components: "@/components",
utils: "@/lib/utils",
ui: "@/components/ui",
lib: "@/lib",
hooks: "@/hooks",
},
resolvedPaths: {
cwd: "/apps/web",
tailwindConfig: "/apps/web/tailwind.config.ts",
tailwindCss: "/apps/web/app/globals.css",
utils: "/apps/web/lib/utils",
components: "/apps/web/components",
lib: "/apps/web/lib",
hooks: "/apps/web/hooks",
ui: "/apps/web/components/ui",
},
...overrides,
} as Config
}
describe("addComponents workspace routing", () => {
test("should route registry:hook files to workspaceConfig.hooks", async () => {
const appConfig = createMockConfig()
const uiConfig = createMockConfig({
resolvedPaths: {
cwd: "/packages/ui",
tailwindConfig: "/packages/ui/tailwind.config.ts",
tailwindCss: "/packages/ui/src/globals.css",
utils: "/packages/ui/src/lib/utils",
components: "/packages/ui/src/components",
lib: "/packages/ui/src/lib",
hooks: "/packages/ui/src/hooks",
ui: "/packages/ui/src/components/ui",
},
})
// Hooks config resolves to the same package but is a distinct config object.
// getWorkspaceConfig builds one config per alias key.
const hooksConfig = createMockConfig({
resolvedPaths: {
cwd: "/packages/ui",
tailwindConfig: "/packages/ui/tailwind.config.ts",
tailwindCss: "/packages/ui/src/globals.css",
utils: "/packages/ui/src/lib/utils",
components: "/packages/ui/src/components",
lib: "/packages/ui/src/lib",
hooks: "/packages/ui/src/hooks",
ui: "/packages/ui/src/components/ui",
},
})
vi.mocked(getWorkspaceConfig).mockResolvedValue({
ui: uiConfig,
hooks: hooksConfig,
})
vi.mocked(resolveRegistryTree).mockResolvedValue({
name: "sidebar",
files: [
{
path: "registry/ui/sidebar.tsx",
type: "registry:ui",
content: "export function Sidebar() {}",
},
{
path: "registry/hooks/use-mobile.ts",
type: "registry:hook",
content: "export function useMobile() {}",
},
],
dependencies: [],
devDependencies: [],
})
vi.mocked(findPackageRoot).mockResolvedValue("/packages/ui")
await addComponents(["sidebar"], appConfig, { silent: true })
// updateFiles should be called twice: once for registry:ui, once for registry:hook.
expect(updateFiles).toHaveBeenCalledTimes(2)
// First call: registry:ui files with the UI workspace config.
expect(updateFiles).toHaveBeenNthCalledWith(
1,
[
expect.objectContaining({
type: "registry:ui",
path: "registry/ui/sidebar.tsx",
}),
],
uiConfig,
expect.any(Object)
)
// Second call: registry:hook files with the hooks workspace config.
expect(updateFiles).toHaveBeenNthCalledWith(
2,
[
expect.objectContaining({
type: "registry:hook",
path: "registry/hooks/use-mobile.ts",
}),
],
hooksConfig,
expect.any(Object)
)
})
test("should route registry:lib files to workspaceConfig.lib", async () => {
const appConfig = createMockConfig()
const uiConfig = createMockConfig({
resolvedPaths: {
cwd: "/packages/ui",
tailwindConfig: "/packages/ui/tailwind.config.ts",
tailwindCss: "/packages/ui/src/globals.css",
utils: "/packages/ui/src/lib/utils",
components: "/packages/ui/src/components",
lib: "/packages/ui/src/lib",
hooks: "/packages/ui/src/hooks",
ui: "/packages/ui/src/components/ui",
},
})
const libConfig = createMockConfig({
resolvedPaths: {
cwd: "/packages/ui",
tailwindConfig: "/packages/ui/tailwind.config.ts",
tailwindCss: "/packages/ui/src/globals.css",
utils: "/packages/ui/src/lib/utils",
components: "/packages/ui/src/components",
lib: "/packages/ui/src/lib",
hooks: "/packages/ui/src/hooks",
ui: "/packages/ui/src/components/ui",
},
})
vi.mocked(getWorkspaceConfig).mockResolvedValue({
ui: uiConfig,
lib: libConfig,
})
vi.mocked(resolveRegistryTree).mockResolvedValue({
name: "button",
files: [
{
path: "registry/ui/button.tsx",
type: "registry:ui",
content: "export function Button() {}",
},
{
path: "registry/lib/utils.ts",
type: "registry:lib",
content: "export function cn() {}",
},
],
dependencies: [],
devDependencies: [],
})
vi.mocked(findPackageRoot).mockResolvedValue("/packages/ui")
await addComponents(["button"], appConfig, { silent: true })
expect(updateFiles).toHaveBeenCalledTimes(2)
// First call: registry:ui files with the UI workspace config.
expect(updateFiles).toHaveBeenNthCalledWith(
1,
[expect.objectContaining({ type: "registry:ui" })],
uiConfig,
expect.any(Object)
)
// Second call: registry:lib files with the lib workspace config.
expect(updateFiles).toHaveBeenNthCalledWith(
2,
[expect.objectContaining({ type: "registry:lib" })],
libConfig,
expect.any(Object)
)
})
test("should fall back to app config for unmapped types like registry:component", async () => {
const appConfig = createMockConfig()
const uiConfig = createMockConfig({
resolvedPaths: {
cwd: "/packages/ui",
tailwindConfig: "/packages/ui/tailwind.config.ts",
tailwindCss: "/packages/ui/src/globals.css",
utils: "/packages/ui/src/lib/utils",
components: "/packages/ui/src/components",
lib: "/packages/ui/src/lib",
hooks: "/packages/ui/src/hooks",
ui: "/packages/ui/src/components/ui",
},
})
vi.mocked(getWorkspaceConfig).mockResolvedValue({
ui: uiConfig,
})
vi.mocked(resolveRegistryTree).mockResolvedValue({
name: "login-01",
files: [
{
path: "registry/components/login-form.tsx",
type: "registry:component",
content: "export function LoginForm() {}",
},
],
dependencies: [],
devDependencies: [],
})
vi.mocked(findPackageRoot).mockResolvedValue("/apps/web")
await addComponents(["login-01"], appConfig, { silent: true })
expect(updateFiles).toHaveBeenCalledTimes(1)
// registry:component should fall back to the app config.
expect(updateFiles).toHaveBeenNthCalledWith(
1,
[expect.objectContaining({ type: "registry:component" })],
appConfig,
expect.any(Object)
)
})
test("should fall back to app config when workspace key is missing", async () => {
const appConfig = createMockConfig()
const uiConfig = createMockConfig({
resolvedPaths: {
cwd: "/packages/ui",
tailwindConfig: "/packages/ui/tailwind.config.ts",
tailwindCss: "/packages/ui/src/globals.css",
utils: "/packages/ui/src/lib/utils",
components: "/packages/ui/src/components",
lib: "/packages/ui/src/lib",
hooks: "/packages/ui/src/hooks",
ui: "/packages/ui/src/components/ui",
},
})
// Workspace config only has ui — no hooks or lib.
vi.mocked(getWorkspaceConfig).mockResolvedValue({
ui: uiConfig,
})
vi.mocked(resolveRegistryTree).mockResolvedValue({
name: "sidebar",
files: [
{
path: "registry/ui/sidebar.tsx",
type: "registry:ui",
content: "export function Sidebar() {}",
},
{
path: "registry/hooks/use-mobile.ts",
type: "registry:hook",
content: "export function useMobile() {}",
},
],
dependencies: [],
devDependencies: [],
})
vi.mocked(findPackageRoot).mockResolvedValue("/packages/ui")
await addComponents(["sidebar"], appConfig, { silent: true })
expect(updateFiles).toHaveBeenCalledTimes(2)
// registry:ui → workspaceConfig.ui.
expect(updateFiles).toHaveBeenNthCalledWith(
1,
[expect.objectContaining({ type: "registry:ui" })],
uiConfig,
expect.any(Object)
)
// registry:hook with no workspaceConfig.hooks → falls back to app config.
expect(updateFiles).toHaveBeenNthCalledWith(
2,
[expect.objectContaining({ type: "registry:hook" })],
appConfig,
expect.any(Object)
)
})
test("should route all three mapped types to their workspace configs", async () => {
const appConfig = createMockConfig()
const uiConfig = createMockConfig({
resolvedPaths: {
cwd: "/packages/ui",
tailwindConfig: "/packages/ui/tailwind.config.ts",
tailwindCss: "/packages/ui/src/globals.css",
utils: "/packages/ui/src/lib/utils",
components: "/packages/ui/src/components",
lib: "/packages/ui/src/lib",
hooks: "/packages/ui/src/hooks",
ui: "/packages/ui/src/components/ui",
},
})
vi.mocked(getWorkspaceConfig).mockResolvedValue({
ui: uiConfig,
hooks: uiConfig,
lib: uiConfig,
})
vi.mocked(resolveRegistryTree).mockResolvedValue({
name: "sidebar",
files: [
{
path: "registry/ui/sidebar.tsx",
type: "registry:ui",
content: "export function Sidebar() {}",
},
{
path: "registry/hooks/use-mobile.ts",
type: "registry:hook",
content: "export function useMobile() {}",
},
{
path: "registry/lib/utils.ts",
type: "registry:lib",
content: "export function cn() {}",
},
],
dependencies: [],
devDependencies: [],
})
vi.mocked(findPackageRoot).mockResolvedValue("/packages/ui")
await addComponents(["sidebar"], appConfig, { silent: true })
// Three calls: one per type.
expect(updateFiles).toHaveBeenCalledTimes(3)
expect(updateFiles).toHaveBeenNthCalledWith(
1,
[expect.objectContaining({ type: "registry:ui" })],
uiConfig,
expect.any(Object)
)
expect(updateFiles).toHaveBeenNthCalledWith(
2,
[expect.objectContaining({ type: "registry:hook" })],
uiConfig,
expect.any(Object)
)
expect(updateFiles).toHaveBeenNthCalledWith(
3,
[expect.objectContaining({ type: "registry:lib" })],
uiConfig,
expect.any(Object)
)
})
test("should fall back to app config for registry:file", async () => {
const appConfig = createMockConfig()
const uiConfig = createMockConfig({
resolvedPaths: {
cwd: "/packages/ui",
tailwindConfig: "/packages/ui/tailwind.config.ts",
tailwindCss: "/packages/ui/src/globals.css",
utils: "/packages/ui/src/lib/utils",
components: "/packages/ui/src/components",
lib: "/packages/ui/src/lib",
hooks: "/packages/ui/src/hooks",
ui: "/packages/ui/src/components/ui",
},
})
vi.mocked(getWorkspaceConfig).mockResolvedValue({
ui: uiConfig,
})
vi.mocked(resolveRegistryTree).mockResolvedValue({
name: "some-component",
files: [
{
path: "registry/ui/button.tsx",
type: "registry:ui",
content: "export function Button() {}",
},
{
path: ".env",
type: "registry:file",
target: "~/.env",
content: "API_KEY=xxx",
},
],
dependencies: [],
devDependencies: [],
})
vi.mocked(findPackageRoot).mockResolvedValue("/packages/ui")
await addComponents(["some-component"], appConfig, { silent: true })
expect(updateFiles).toHaveBeenCalledTimes(2)
// registry:ui → workspace.
expect(updateFiles).toHaveBeenNthCalledWith(
1,
[expect.objectContaining({ type: "registry:ui" })],
uiConfig,
expect.any(Object)
)
// registry:file → app config.
expect(updateFiles).toHaveBeenNthCalledWith(
2,
[expect.objectContaining({ type: "registry:file" })],
appConfig,
expect.any(Object)
)
})
test("should default files with no type to registry:ui", async () => {
const appConfig = createMockConfig()
const uiConfig = createMockConfig({
resolvedPaths: {
cwd: "/packages/ui",
tailwindConfig: "/packages/ui/tailwind.config.ts",
tailwindCss: "/packages/ui/src/globals.css",
utils: "/packages/ui/src/lib/utils",
components: "/packages/ui/src/components",
lib: "/packages/ui/src/lib",
hooks: "/packages/ui/src/hooks",
ui: "/packages/ui/src/components/ui",
},
})
vi.mocked(getWorkspaceConfig).mockResolvedValue({
ui: uiConfig,
})
vi.mocked(resolveRegistryTree).mockResolvedValue({
name: "button",
files: [
{
path: "registry/ui/button.tsx",
// No type — should default to registry:ui.
content: "export function Button() {}",
},
],
dependencies: [],
devDependencies: [],
})
vi.mocked(findPackageRoot).mockResolvedValue("/packages/ui")
await addComponents(["button"], appConfig, { silent: true })
expect(updateFiles).toHaveBeenCalledTimes(1)
// Should route to workspace UI config.
expect(updateFiles).toHaveBeenNthCalledWith(
1,
expect.any(Array),
uiConfig,
expect.any(Object)
)
})
test("should group multiple files of the same type into one updateFiles call", async () => {
const appConfig = createMockConfig()
const uiConfig = createMockConfig({
resolvedPaths: {
cwd: "/packages/ui",
tailwindConfig: "/packages/ui/tailwind.config.ts",
tailwindCss: "/packages/ui/src/globals.css",
utils: "/packages/ui/src/lib/utils",
components: "/packages/ui/src/components",
lib: "/packages/ui/src/lib",
hooks: "/packages/ui/src/hooks",
ui: "/packages/ui/src/components/ui",
},
})
vi.mocked(getWorkspaceConfig).mockResolvedValue({
ui: uiConfig,
})
vi.mocked(resolveRegistryTree).mockResolvedValue({
name: "sidebar",
files: [
{
path: "registry/ui/sidebar.tsx",
type: "registry:ui",
content: "export function Sidebar() {}",
},
{
path: "registry/ui/sidebar-nav.tsx",
type: "registry:ui",
content: "export function SidebarNav() {}",
},
{
path: "registry/ui/sidebar-menu.tsx",
type: "registry:ui",
content: "export function SidebarMenu() {}",
},
],
dependencies: [],
devDependencies: [],
})
vi.mocked(findPackageRoot).mockResolvedValue("/packages/ui")
await addComponents(["sidebar"], appConfig, { silent: true })
// All three files are registry:ui — should be one call, not three.
expect(updateFiles).toHaveBeenCalledTimes(1)
expect(updateFiles).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ path: "registry/ui/sidebar.tsx" }),
expect.objectContaining({ path: "registry/ui/sidebar-nav.tsx" }),
expect.objectContaining({ path: "registry/ui/sidebar-menu.tsx" }),
]),
uiConfig,
expect.any(Object)
)
})
test("should route hooks to separate package when aliases differ", async () => {
const appConfig = createMockConfig({
aliases: {
components: "~foo/ui/components",
utils: "~foo/ui/lib/utils",
hooks: "~foo/hooks/src",
lib: "~foo/ui/lib",
ui: "~foo/ui/components",
},
})
const uiPackageConfig = createMockConfig({
resolvedPaths: {
cwd: "/packages/ui",
tailwindConfig: "/packages/ui/tailwind.config.ts",
tailwindCss: "/packages/ui/src/globals.css",
utils: "/packages/ui/src/lib/utils",
components: "/packages/ui/src/components",
lib: "/packages/ui/src/lib",
hooks: "/packages/ui/src/hooks",
ui: "/packages/ui/src/components/ui",
},
})
const hooksPackageConfig = createMockConfig({
resolvedPaths: {
cwd: "/packages/hooks",
tailwindConfig: "/packages/hooks/tailwind.config.ts",
tailwindCss: "/packages/hooks/src/globals.css",
utils: "/packages/hooks/src/lib/utils",
components: "/packages/hooks/src/components",
lib: "/packages/hooks/src/lib",
hooks: "/packages/hooks/src/hooks",
ui: "/packages/hooks/src/components/ui",
},
})
vi.mocked(getWorkspaceConfig).mockResolvedValue({
ui: uiPackageConfig,
hooks: hooksPackageConfig,
lib: uiPackageConfig,
})
vi.mocked(resolveRegistryTree).mockResolvedValue({
name: "sidebar",
files: [
{
path: "registry/ui/sidebar.tsx",
type: "registry:ui",
content: "export function Sidebar() {}",
},
{
path: "registry/hooks/use-mobile.ts",
type: "registry:hook",
content: "export function useMobile() {}",
},
],
dependencies: [],
devDependencies: [],
})
vi.mocked(findPackageRoot).mockImplementation(
async (_cwd, resolvedPath) => {
if (resolvedPath.startsWith("/packages/hooks")) {
return "/packages/hooks"
}
return "/packages/ui"
}
)
await addComponents(["sidebar"], appConfig, { silent: true })
expect(updateFiles).toHaveBeenCalledTimes(2)
// registry:ui → UI package.
expect(updateFiles).toHaveBeenNthCalledWith(
1,
[expect.objectContaining({ type: "registry:ui" })],
uiPackageConfig,
expect.any(Object)
)
// registry:hook → hooks package (separate from UI).
expect(updateFiles).toHaveBeenNthCalledWith(
2,
[expect.objectContaining({ type: "registry:hook" })],
hooksPackageConfig,
expect.any(Object)
)
})
test("should call updateFonts with app config, not workspace config", async () => {
const appConfig = createMockConfig()
const uiConfig = createMockConfig({
resolvedPaths: {
cwd: "/packages/ui",
tailwindConfig: "/packages/ui/tailwind.config.ts",
tailwindCss: "/packages/ui/src/globals.css",
utils: "/packages/ui/src/lib/utils",
components: "/packages/ui/src/components",
lib: "/packages/ui/src/lib",
hooks: "/packages/ui/src/hooks",
ui: "/packages/ui/src/components/ui",
},
})
vi.mocked(getWorkspaceConfig).mockResolvedValue({
ui: uiConfig,
})
vi.mocked(resolveRegistryTree).mockResolvedValue({
name: "button",
files: [
{
path: "registry/ui/button.tsx",
type: "registry:ui",
content: "export function Button() {}",
},
],
fonts: [
{
name: "font-inter",
type: "registry:font",
title: "Inter",
font: {
provider: "google",
import: "Inter",
family: "Inter, sans-serif",
variable: "--font-sans",
weight: ["400", "500", "600", "700"],
subsets: ["latin"],
},
},
],
dependencies: [],
devDependencies: [],
})
vi.mocked(findPackageRoot).mockResolvedValue("/packages/ui")
await addComponents(["button"], appConfig, { silent: true })
// updateFonts should use the app config (layout lives in the app).
expect(updateFonts).toHaveBeenCalledWith(
expect.any(Array),
appConfig,
expect.any(Object)
)
// Verify it was NOT called with the workspace UI config.
expect(updateFonts).not.toHaveBeenCalledWith(
expect.anything(),
uiConfig,
expect.anything()
)
})
})

View File

@@ -44,4 +44,33 @@ describe("get tailwindcss file", async () => {
)
).toBe(file)
})
test("should use configCssFile when provided and file exists", async () => {
const cwd = path.resolve(
__dirname,
"../fixtures/frameworks/next-monorepo"
)
expect(
await getTailwindCssFile(cwd, "packages/ui/src/globals.css")
).toBe("packages/ui/src/globals.css")
})
test("should fall back to glob when configCssFile does not exist", async () => {
const cwd = path.resolve(__dirname, "../fixtures/frameworks/next-app")
expect(
await getTailwindCssFile(cwd, "nonexistent/styles.css")
).toBe("app/globals.css")
})
test("should return null when no css file found and no configCssFile", async () => {
const cwd = path.resolve(
__dirname,
"../fixtures/frameworks/next-monorepo"
)
// The CSS file is nested under packages/ which the glob finds.
// Without configCssFile, it should still find it via glob.
expect(await getTailwindCssFile(cwd)).toBe(
"packages/ui/src/globals.css"
)
})
})

View File

@@ -0,0 +1,7 @@
dist/
node_modules/
.next/
.turbo/
coverage/
pnpm-lock.yaml
.pnpm-store/

View File

@@ -1,10 +1,13 @@
// This configuration only applies to the package manager root.
// Root-level ESLint config for a Turborepo workspace.
// App/package lint rules live in each workspace's eslint.config.js.
/** @type {import("eslint").Linter.Config} */
module.exports = {
ignorePatterns: ["apps/**", "packages/**"],
extends: ["@workspace/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
root: true,
ignorePatterns: [
"**/node_modules/**",
"**/.next/**",
"**/dist/**",
"**/.turbo/**",
"**/coverage/**",
],
}

View File

@@ -0,0 +1,7 @@
dist/
node_modules/
.next/
.turbo/
coverage/
pnpm-lock.yaml
.pnpm-store/

View File

@@ -27,6 +27,6 @@ export const config = [
},
},
{
ignores: ["dist/**"],
ignores: ["dist/**", ".next/**", "**/.turbo/**", "**/coverage/**"],
},
]

View File

@@ -0,0 +1,7 @@
node_modules/
coverage/
.pnpm-store/
pnpm-lock.yaml
package-lock.json
pnpm-lock.yaml
yarn.lock

View File

@@ -0,0 +1,7 @@
node_modules/
coverage/
.pnpm-store/
pnpm-lock.yaml
package-lock.json
pnpm-lock.yaml
yarn.lock