mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-07 22:18:39 +00:00
feat: new CLI, Styles and more (#637)
This commit is contained in:
178
packages/cli/src/commands/add.ts
Normal file
178
packages/cli/src/commands/add.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { existsSync, promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { getConfig } from "@/src/utils/get-config"
|
||||
import { getPackageManager } from "@/src/utils/get-package-manager"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import {
|
||||
fetchTree,
|
||||
getItemTargetPath,
|
||||
getRegistryBaseColor,
|
||||
getRegistryIndex,
|
||||
resolveTree,
|
||||
} from "@/src/utils/registry"
|
||||
import { transform } from "@/src/utils/transformers"
|
||||
import chalk from "chalk"
|
||||
import { Command } from "commander"
|
||||
import { execa } from "execa"
|
||||
import ora from "ora"
|
||||
import prompts from "prompts"
|
||||
import * as z from "zod"
|
||||
|
||||
const addOptionsSchema = z.object({
|
||||
components: z.array(z.string()).optional(),
|
||||
yes: z.boolean(),
|
||||
overwrite: z.boolean(),
|
||||
cwd: z.string(),
|
||||
path: z.string().optional(),
|
||||
})
|
||||
|
||||
export const add = new Command()
|
||||
.name("add")
|
||||
.description("add a component to your project")
|
||||
.argument("[components...]", "the components to add")
|
||||
.option("-y, --yes", "skip confirmation prompt.", false)
|
||||
.option("-o, --overwrite", "overwrite existing files.", false)
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.option("-p, --path <path>", "the path to add the component to.")
|
||||
.action(async (components, opts) => {
|
||||
try {
|
||||
const options = addOptionsSchema.parse({
|
||||
components,
|
||||
...opts,
|
||||
})
|
||||
|
||||
const cwd = path.resolve(options.cwd)
|
||||
|
||||
if (!existsSync(cwd)) {
|
||||
logger.error(`The path ${cwd} does not exist. Please try again.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const config = await getConfig(cwd)
|
||||
if (!config) {
|
||||
logger.warn(
|
||||
`Configuration is missing. Please run ${chalk.green(
|
||||
`init`
|
||||
)} to create a components.json file.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const registryIndex = await getRegistryIndex()
|
||||
|
||||
let selectedComponents = options.components
|
||||
if (!options.components?.length) {
|
||||
const { components } = await prompts({
|
||||
type: "multiselect",
|
||||
name: "components",
|
||||
message: "Which components would you like to add?",
|
||||
hint: "Space to select. A to toggle all. Enter to submit.",
|
||||
instructions: false,
|
||||
choices: registryIndex.map((entry) => ({
|
||||
title: entry.name,
|
||||
value: entry.name,
|
||||
})),
|
||||
})
|
||||
selectedComponents = components
|
||||
}
|
||||
|
||||
if (!selectedComponents?.length) {
|
||||
logger.warn("No components selected. Exiting.")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const tree = await resolveTree(registryIndex, selectedComponents)
|
||||
const payload = await fetchTree(config.style, tree)
|
||||
const baseColor = await getRegistryBaseColor(config.tailwind.baseColor)
|
||||
|
||||
if (!payload.length) {
|
||||
logger.warn("Selected components not found. Exiting.")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!options.yes) {
|
||||
const { proceed } = await prompts({
|
||||
type: "confirm",
|
||||
name: "proceed",
|
||||
message: `Ready to install components and dependencies. Proceed?`,
|
||||
initial: true,
|
||||
})
|
||||
|
||||
if (!proceed) {
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = ora(`Installing components...`).start()
|
||||
for (const item of payload) {
|
||||
spinner.text = `Installing ${item.name}...`
|
||||
const targetDir = await getItemTargetPath(
|
||||
config,
|
||||
item,
|
||||
options.path ? path.resolve(cwd, options.path) : undefined
|
||||
)
|
||||
|
||||
if (!targetDir) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!existsSync(targetDir)) {
|
||||
await fs.mkdir(targetDir, { recursive: true })
|
||||
}
|
||||
|
||||
const existingComponent = item.files.filter((file) =>
|
||||
existsSync(path.resolve(targetDir, file.name))
|
||||
)
|
||||
|
||||
if (existingComponent.length && !options.overwrite) {
|
||||
if (selectedComponents.includes(item.name)) {
|
||||
logger.warn(
|
||||
`Component ${item.name} already exists. Use ${chalk.green(
|
||||
"--overwrite"
|
||||
)} to overwrite.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
for (const file of item.files) {
|
||||
const filePath = path.resolve(targetDir, file.name)
|
||||
|
||||
// Run transformers.
|
||||
const content = await transform({
|
||||
filename: file.name,
|
||||
raw: file.content,
|
||||
config,
|
||||
baseColor,
|
||||
})
|
||||
|
||||
await fs.writeFile(filePath, content)
|
||||
}
|
||||
|
||||
// Install dependencies.
|
||||
if (item.dependencies?.length) {
|
||||
const packageManager = await getPackageManager(cwd)
|
||||
await execa(
|
||||
packageManager,
|
||||
[
|
||||
packageManager === "npm" ? "install" : "add",
|
||||
...item.dependencies,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
spinner.succeed(`Done.`)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
})
|
||||
196
packages/cli/src/commands/diff.ts
Normal file
196
packages/cli/src/commands/diff.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { existsSync, promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { Config, getConfig } from "@/src/utils/get-config"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import {
|
||||
fetchTree,
|
||||
getItemTargetPath,
|
||||
getRegistryBaseColor,
|
||||
getRegistryIndex,
|
||||
} from "@/src/utils/registry"
|
||||
import { registryIndexSchema } from "@/src/utils/registry/schema"
|
||||
import { transform } from "@/src/utils/transformers"
|
||||
import chalk from "chalk"
|
||||
import { Command } from "commander"
|
||||
import { diffLines, type Change } from "diff"
|
||||
import * as z from "zod"
|
||||
|
||||
const updateOptionsSchema = z.object({
|
||||
component: z.string().optional(),
|
||||
yes: z.boolean(),
|
||||
cwd: z.string(),
|
||||
path: z.string().optional(),
|
||||
})
|
||||
|
||||
export const diff = new Command()
|
||||
.name("diff")
|
||||
.description("check for updates against the registry")
|
||||
.argument("[component]", "the component name")
|
||||
.option("-y, --yes", "skip confirmation prompt.", false)
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.action(async (name, opts) => {
|
||||
try {
|
||||
const options = updateOptionsSchema.parse({
|
||||
component: name,
|
||||
...opts,
|
||||
})
|
||||
|
||||
const cwd = path.resolve(options.cwd)
|
||||
|
||||
if (!existsSync(cwd)) {
|
||||
logger.error(`The path ${cwd} does not exist. Please try again.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const config = await getConfig(cwd)
|
||||
if (!config) {
|
||||
logger.warn(
|
||||
`Configuration is missing. Please run ${chalk.green(
|
||||
`init`
|
||||
)} to create a components.json file.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const registryIndex = await getRegistryIndex()
|
||||
|
||||
if (!options.component) {
|
||||
const targetDir = config.resolvedPaths.components
|
||||
|
||||
// Find all components that exist in the project.
|
||||
const projectComponents = registryIndex.filter((item) => {
|
||||
for (const file of item.files) {
|
||||
const filePath = path.resolve(targetDir, file)
|
||||
if (existsSync(filePath)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// Check for updates.
|
||||
const componentsWithUpdates = []
|
||||
for (const component of projectComponents) {
|
||||
const changes = await diffComponent(component, config)
|
||||
if (changes.length) {
|
||||
componentsWithUpdates.push({
|
||||
name: component.name,
|
||||
changes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!componentsWithUpdates.length) {
|
||||
logger.info("No updates found.")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
logger.info("The following components have updates available:")
|
||||
for (const component of componentsWithUpdates) {
|
||||
logger.info(`- ${component.name}`)
|
||||
for (const change of component.changes) {
|
||||
logger.info(` - ${change.filePath}`)
|
||||
}
|
||||
}
|
||||
logger.break()
|
||||
logger.info(
|
||||
`Run ${chalk.green(`diff <component>`)} to see the changes.`
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Show diff for a single component.
|
||||
const component = registryIndex.find(
|
||||
(item) => item.name === options.component
|
||||
)
|
||||
|
||||
if (!component) {
|
||||
logger.error(
|
||||
`The component ${chalk.green(options.component)} does not exist.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const changes = await diffComponent(component, config)
|
||||
|
||||
if (!changes.length) {
|
||||
logger.info(`No updates found for ${options.component}.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
for (const change of changes) {
|
||||
logger.info(`- ${change.filePath}`)
|
||||
await printDiff(change.patch)
|
||||
logger.info("")
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
})
|
||||
|
||||
async function diffComponent(
|
||||
component: z.infer<typeof registryIndexSchema>[number],
|
||||
config: Config
|
||||
) {
|
||||
const payload = await fetchTree(config.style, [component])
|
||||
const baseColor = await getRegistryBaseColor(config.tailwind.baseColor)
|
||||
|
||||
const changes = []
|
||||
|
||||
for (const item of payload) {
|
||||
const targetDir = await getItemTargetPath(config, item)
|
||||
|
||||
if (!targetDir) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const file of item.files) {
|
||||
const filePath = path.resolve(targetDir, file.name)
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const fileContent = await fs.readFile(filePath, "utf8")
|
||||
|
||||
const registryContent = await transform({
|
||||
filename: file.name,
|
||||
raw: file.content,
|
||||
config,
|
||||
baseColor,
|
||||
})
|
||||
|
||||
const patch = diffLines(registryContent, fileContent)
|
||||
if (patch.length > 1) {
|
||||
changes.push({
|
||||
file: file.name,
|
||||
filePath,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
async function printDiff(diff: Change[]) {
|
||||
diff.forEach((part) => {
|
||||
if (part) {
|
||||
if (part.added) {
|
||||
return process.stdout.write(chalk.green(part.value))
|
||||
}
|
||||
if (part.removed) {
|
||||
return process.stdout.write(chalk.red(part.value))
|
||||
}
|
||||
|
||||
return process.stdout.write(part.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
264
packages/cli/src/commands/init.ts
Normal file
264
packages/cli/src/commands/init.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { existsSync, promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import {
|
||||
DEFAULT_COMPONENTS,
|
||||
DEFAULT_TAILWIND_BASE_COLOR,
|
||||
DEFAULT_TAILWIND_CONFIG,
|
||||
DEFAULT_TAILWIND_CSS,
|
||||
DEFAULT_UTILS,
|
||||
getConfig,
|
||||
rawConfigSchema,
|
||||
resolveConfigPaths,
|
||||
type Config,
|
||||
} from "@/src/utils/get-config"
|
||||
import { getPackageManager } from "@/src/utils/get-package-manager"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import {
|
||||
getRegistryBaseColor,
|
||||
getRegistryBaseColors,
|
||||
getRegistryStyles,
|
||||
} from "@/src/utils/registry"
|
||||
import * as templates from "@/src/utils/templates"
|
||||
import chalk from "chalk"
|
||||
import { Command } from "commander"
|
||||
import { execa } from "execa"
|
||||
import ora from "ora"
|
||||
import prompts from "prompts"
|
||||
import * as z from "zod"
|
||||
|
||||
const PROJECT_DEPENDENCIES = [
|
||||
"tailwindcss-animate",
|
||||
"class-variance-authority",
|
||||
"clsx",
|
||||
"tailwind-merge",
|
||||
]
|
||||
|
||||
const initOptionsSchema = z.object({
|
||||
cwd: z.string(),
|
||||
yes: z.boolean(),
|
||||
})
|
||||
|
||||
export const init = new Command()
|
||||
.name("init")
|
||||
.description("initialize your project and install dependencies")
|
||||
.option("-y, --yes", "skip confirmation prompt.", false)
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const options = initOptionsSchema.parse(opts)
|
||||
const cwd = path.resolve(options.cwd)
|
||||
|
||||
// Ensure target directory exists.
|
||||
if (!existsSync(cwd)) {
|
||||
logger.error(`The path ${cwd} does not exist. Please try again.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Read config.
|
||||
const existingConfig = await getConfig(cwd)
|
||||
const config = await promptForConfig(cwd, existingConfig, options.yes)
|
||||
|
||||
await runInit(cwd, config)
|
||||
|
||||
logger.info("")
|
||||
logger.info(
|
||||
`${chalk.green("Success!")} Project initialization completed.`
|
||||
)
|
||||
logger.info("")
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
})
|
||||
|
||||
export async function promptForConfig(
|
||||
cwd: string,
|
||||
defaultConfig: Config | null = null,
|
||||
skip = false
|
||||
) {
|
||||
const highlight = (text: string) => chalk.cyan(text)
|
||||
|
||||
const styles = await getRegistryStyles()
|
||||
const baseColors = await getRegistryBaseColors()
|
||||
|
||||
const options = await prompts([
|
||||
{
|
||||
type: "select",
|
||||
name: "style",
|
||||
message: `Which ${highlight("style")} would you like to use?`,
|
||||
choices: styles.map((style) => ({
|
||||
title: style.label,
|
||||
value: style.name,
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
name: "tailwindBaseColor",
|
||||
message: `Which color would you like to use as ${highlight(
|
||||
"base color"
|
||||
)}?`,
|
||||
choices: baseColors.map((color) => ({
|
||||
title: color.label,
|
||||
value: color.name,
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "tailwindCss",
|
||||
message: `Where is your ${highlight("global CSS")} file?`,
|
||||
initial: defaultConfig?.tailwind.css ?? DEFAULT_TAILWIND_CSS,
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "tailwindCssVariables",
|
||||
message: `Do you want to use ${highlight("CSS variables")} for colors?`,
|
||||
initial: defaultConfig?.tailwind.cssVariables ?? true,
|
||||
active: "yes",
|
||||
inactive: "no",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "tailwindConfig",
|
||||
message: `Where is your ${highlight("tailwind.config.js")} located?`,
|
||||
initial: defaultConfig?.tailwind.config ?? DEFAULT_TAILWIND_CONFIG,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "components",
|
||||
message: `Configure the import alias for ${highlight("components")}:`,
|
||||
initial: defaultConfig?.aliases["components"] ?? DEFAULT_COMPONENTS,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "utils",
|
||||
message: `Configure the import alias for ${highlight("utils")}:`,
|
||||
initial: defaultConfig?.aliases["utils"] ?? DEFAULT_UTILS,
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "rsc",
|
||||
message: `Are you using ${highlight("React Server Components")}?`,
|
||||
initial: defaultConfig?.rsc ?? true,
|
||||
active: "yes",
|
||||
inactive: "no",
|
||||
},
|
||||
])
|
||||
|
||||
const config = rawConfigSchema.parse({
|
||||
$schema: "https://ui.shadcn.com/schema.json",
|
||||
style: options.style,
|
||||
tailwind: {
|
||||
config: options.tailwindConfig,
|
||||
css: options.tailwindCss,
|
||||
baseColor: options.tailwindBaseColor,
|
||||
cssVariables: options.tailwindCssVariables,
|
||||
},
|
||||
rsc: options.rsc,
|
||||
aliases: {
|
||||
utils: options.utils,
|
||||
components: options.components,
|
||||
},
|
||||
})
|
||||
|
||||
if (!skip) {
|
||||
const { proceed } = await prompts({
|
||||
type: "confirm",
|
||||
name: "proceed",
|
||||
message: `Write configuration to ${highlight(
|
||||
"components.json"
|
||||
)}. Proceed?`,
|
||||
initial: true,
|
||||
})
|
||||
|
||||
if (!proceed) {
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Write to file.
|
||||
logger.info("")
|
||||
const spinner = ora(`Writing components.json...`).start()
|
||||
const targetPath = path.resolve(cwd, "components.json")
|
||||
await fs.writeFile(targetPath, JSON.stringify(config, null, 2), "utf8")
|
||||
spinner.succeed()
|
||||
|
||||
return await resolveConfigPaths(cwd, config)
|
||||
}
|
||||
|
||||
export async function runInit(cwd: string, config: Config) {
|
||||
const spinner = ora(`Initializing project...`)?.start()
|
||||
|
||||
// Ensure all resolved paths directories exist.
|
||||
for (const [key, resolvedPath] of Object.entries(config.resolvedPaths)) {
|
||||
// Determine if the path is a file or directory.
|
||||
// TODO: is there a better way to do this?
|
||||
let dirname = path.extname(resolvedPath)
|
||||
? path.dirname(resolvedPath)
|
||||
: resolvedPath
|
||||
|
||||
// If the utils alias is set to something like "@/lib/utils",
|
||||
// assume this is a file and remove the "utils" file name.
|
||||
// TODO: In future releases we should add support for individual utils.
|
||||
if (key === "utils" && resolvedPath.endsWith("/utils")) {
|
||||
// Remove /utils at the end.
|
||||
dirname = dirname.replace(/\/utils$/, "")
|
||||
}
|
||||
|
||||
if (!existsSync(dirname)) {
|
||||
await fs.mkdir(dirname, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Write tailwind config.
|
||||
await fs.writeFile(
|
||||
config.resolvedPaths.tailwindConfig,
|
||||
config.tailwind.cssVariables
|
||||
? templates.TAILWIND_CONFIG_WITH_VARIABLES
|
||||
: templates.TAILWIND_CONFIG,
|
||||
"utf8"
|
||||
)
|
||||
|
||||
// Write css file.
|
||||
const baseColor = await getRegistryBaseColor(config.tailwind.baseColor)
|
||||
if (baseColor) {
|
||||
await fs.writeFile(
|
||||
config.resolvedPaths.tailwindCss,
|
||||
config.tailwind.cssVariables
|
||||
? baseColor.cssVarsTemplate
|
||||
: baseColor.inlineColorsTemplate,
|
||||
"utf8"
|
||||
)
|
||||
}
|
||||
|
||||
// Write cn file.
|
||||
await fs.writeFile(
|
||||
`${config.resolvedPaths.utils}.ts`,
|
||||
templates.UTILS,
|
||||
"utf8"
|
||||
)
|
||||
|
||||
spinner?.succeed()
|
||||
|
||||
// Install dependencies.
|
||||
const dependenciesSpinner = ora(`Installing dependencies...`)?.start()
|
||||
const packageManager = await getPackageManager(cwd)
|
||||
|
||||
// TODO: add support for other icon libraries.
|
||||
const deps = [
|
||||
...PROJECT_DEPENDENCIES,
|
||||
config.style === "new-york" ? "@radix-ui/react-icons" : "lucide-react",
|
||||
]
|
||||
|
||||
await execa(
|
||||
packageManager,
|
||||
[packageManager === "npm" ? "install" : "add", ...deps],
|
||||
{
|
||||
cwd,
|
||||
}
|
||||
)
|
||||
dependenciesSpinner?.succeed()
|
||||
}
|
||||
@@ -1,18 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync, promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { add } from "@/src/commands/add"
|
||||
import { diff } from "@/src/commands/diff"
|
||||
import { init } from "@/src/commands/init"
|
||||
import { Command } from "commander"
|
||||
import { execa } from "execa"
|
||||
import ora from "ora"
|
||||
import prompts from "prompts"
|
||||
|
||||
import { Component, getAvailableComponents } from "./utils/get-components"
|
||||
import { Config, getCliConfig } from "./utils/get-config"
|
||||
import { getPackageInfo } from "./utils/get-package-info"
|
||||
import { getPackageManager } from "./utils/get-package-manager"
|
||||
import { getProjectInfo } from "./utils/get-project-info"
|
||||
import { logger } from "./utils/logger"
|
||||
import { STYLES, TAILWIND_CONFIG, UTILS } from "./utils/templates"
|
||||
|
||||
process.on("SIGINT", () => process.exit(0))
|
||||
process.on("SIGTERM", () => process.exit(0))
|
||||
@@ -27,209 +19,19 @@ const PROJECT_DEPENDENCIES = [
|
||||
|
||||
async function main() {
|
||||
const packageInfo = await getPackageInfo()
|
||||
const projectInfo = await getProjectInfo()
|
||||
const cliConfig = await getCliConfig()
|
||||
|
||||
const packageManager = getPackageManager()
|
||||
|
||||
const program = new Command()
|
||||
.name("shadcn-ui")
|
||||
.description("Add shadcn-ui components to your project")
|
||||
.description("add components and dependencies to your project")
|
||||
.version(
|
||||
packageInfo.version || "1.0.0",
|
||||
"-v, --version",
|
||||
"display the version number"
|
||||
)
|
||||
|
||||
program
|
||||
.command("init")
|
||||
.description("Configure your Next.js project.")
|
||||
.option("-y, --yes", "Skip confirmation prompt.")
|
||||
.action(async (options) => {
|
||||
logger.warn(
|
||||
"This command assumes a Next.js project with TypeScript and Tailwind CSS."
|
||||
)
|
||||
logger.warn(
|
||||
"If you don't have these, follow the manual steps at https://ui.shadcn.com/docs/installation."
|
||||
)
|
||||
logger.warn("")
|
||||
|
||||
if (!options.yes) {
|
||||
const { proceed } = await prompts({
|
||||
type: "confirm",
|
||||
name: "proceed",
|
||||
message:
|
||||
"Running this command will install dependencies and overwrite your existing tailwind.config.js. Proceed?",
|
||||
initial: true,
|
||||
})
|
||||
|
||||
if (!proceed) {
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Install dependencies.
|
||||
const dependenciesSpinner = ora(`Installing dependencies...`).start()
|
||||
await execa(packageManager, [
|
||||
packageManager === "npm" ? "install" : "add",
|
||||
...PROJECT_DEPENDENCIES,
|
||||
])
|
||||
dependenciesSpinner.succeed()
|
||||
|
||||
// Ensure styles directory exists.
|
||||
if (!projectInfo?.appDir) {
|
||||
const stylesDir = projectInfo?.srcDir ? "./src/styles" : "./styles"
|
||||
if (!existsSync(path.resolve(stylesDir))) {
|
||||
await fs.mkdir(path.resolve(stylesDir), { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Update styles.css
|
||||
let stylesDestination = projectInfo?.srcDir
|
||||
? "./src/styles/globals.css"
|
||||
: "./styles/globals.css"
|
||||
if (projectInfo?.appDir) {
|
||||
stylesDestination = projectInfo?.srcDir
|
||||
? "./src/app/globals.css"
|
||||
: "./app/globals.css"
|
||||
}
|
||||
const stylesSpinner = ora(`Adding styles with CSS variables...`).start()
|
||||
await fs.writeFile(stylesDestination, STYLES, "utf8")
|
||||
stylesSpinner.succeed()
|
||||
|
||||
// Ensure lib directory exists.
|
||||
const libDir = projectInfo?.srcDir ? "./src/lib" : "./lib"
|
||||
if (!existsSync(path.resolve(libDir))) {
|
||||
await fs.mkdir(path.resolve(libDir), { recursive: true })
|
||||
}
|
||||
|
||||
// Create lib/utils.ts
|
||||
const utilsDestination = projectInfo?.srcDir
|
||||
? "./src/lib/utils.ts"
|
||||
: "./lib/utils.ts"
|
||||
const utilsSpinner = ora(`Adding utils...`).start()
|
||||
await fs.writeFile(utilsDestination, UTILS, "utf8")
|
||||
utilsSpinner.succeed()
|
||||
|
||||
const tailwindDestination = "./tailwind.config.js"
|
||||
const tailwindSpinner = ora(`Updating tailwind.config.js...`).start()
|
||||
await fs.writeFile(tailwindDestination, TAILWIND_CONFIG, "utf8")
|
||||
tailwindSpinner.succeed()
|
||||
})
|
||||
|
||||
program
|
||||
.command("add")
|
||||
.description("add components to your project")
|
||||
.argument("[components...]", "name of components")
|
||||
.action(async (components: string[]) => {
|
||||
logger.warn(
|
||||
"Running the following command will overwrite existing files."
|
||||
)
|
||||
logger.warn(
|
||||
"Make sure you have committed your changes before proceeding."
|
||||
)
|
||||
logger.warn("")
|
||||
|
||||
const availableComponents = await getAvailableComponents()
|
||||
|
||||
if (!availableComponents?.length) {
|
||||
logger.error(
|
||||
"An error occurred while fetching components. Please try again."
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
let selectedComponents = availableComponents.filter((component) =>
|
||||
components.includes(component.component)
|
||||
)
|
||||
|
||||
if (!selectedComponents?.length) {
|
||||
selectedComponents = await promptForComponents(availableComponents)
|
||||
}
|
||||
|
||||
const dir = await promptForDestinationDir(cliConfig)
|
||||
|
||||
if (!selectedComponents?.length) {
|
||||
logger.warn("No components selected. Nothing to install.")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Create componentPath directory if it doesn't exist.
|
||||
const destinationDir = path.resolve(dir)
|
||||
if (!existsSync(destinationDir)) {
|
||||
const spinner = ora(`Creating ${dir}...`).start()
|
||||
await fs.mkdir(destinationDir, { recursive: true })
|
||||
spinner.succeed()
|
||||
}
|
||||
|
||||
logger.success(
|
||||
`Installing ${selectedComponents.length} component(s) and dependencies...`
|
||||
)
|
||||
for (const component of selectedComponents) {
|
||||
const componentSpinner = ora(`${component.name}...`).start()
|
||||
|
||||
// Write the files.
|
||||
for (const file of component.files) {
|
||||
// because these are the predefined routes for the utils and components we can
|
||||
// use them as a replacer for the defined routes on the installed file.
|
||||
file.content = file.content.replace(
|
||||
"@/lib/utils",
|
||||
cliConfig.utilsLocation
|
||||
)
|
||||
file.content = file.content.replace(
|
||||
"@/components/ui/",
|
||||
cliConfig.componentDirAlias
|
||||
)
|
||||
|
||||
const filePath = path.resolve(dir, file.name)
|
||||
await fs.writeFile(filePath, file.content)
|
||||
}
|
||||
|
||||
// Install dependencies.
|
||||
if (component.dependencies?.length) {
|
||||
await execa(packageManager, [
|
||||
packageManager === "npm" ? "install" : "add",
|
||||
...component.dependencies,
|
||||
])
|
||||
}
|
||||
componentSpinner.succeed(component.name)
|
||||
}
|
||||
})
|
||||
program.addCommand(init).addCommand(add).addCommand(diff)
|
||||
|
||||
program.parse()
|
||||
}
|
||||
|
||||
async function promptForComponents(components: Component[]) {
|
||||
const { components: selectedComponents } = await prompts({
|
||||
type: "autocompleteMultiselect",
|
||||
name: "components",
|
||||
message: "Which component(s) would you like to add?",
|
||||
hint: "Space to select. A to select all. I to invert selection.",
|
||||
instructions: false,
|
||||
choices: components.map((component) => ({
|
||||
title: component.name,
|
||||
value: component,
|
||||
})),
|
||||
})
|
||||
|
||||
return selectedComponents
|
||||
}
|
||||
|
||||
async function promptForDestinationDir(cliConfig: Config) {
|
||||
if (!cliConfig.askForDir) {
|
||||
return cliConfig.componentsDirInstallation
|
||||
}
|
||||
|
||||
const { dir } = await prompts([
|
||||
{
|
||||
type: "text",
|
||||
name: "dir",
|
||||
message: "Where would you like to install the component(s)?",
|
||||
initial: cliConfig.componentsDirInstallation,
|
||||
},
|
||||
])
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { HttpsProxyAgent } from "https-proxy-agent"
|
||||
import fetch from "node-fetch"
|
||||
import * as z from "zod"
|
||||
|
||||
const baseUrl = process.env.COMPONENTS_BASE_URL ?? "https://ui.shadcn.com"
|
||||
const agent = process.env.https_proxy
|
||||
? new HttpsProxyAgent(process.env.https_proxy)
|
||||
: undefined
|
||||
|
||||
const componentSchema = z.object({
|
||||
component: z.string(),
|
||||
name: z.string(),
|
||||
dependencies: z.array(z.string()).optional(),
|
||||
files: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
dir: z.string(),
|
||||
content: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export type Component = z.infer<typeof componentSchema>
|
||||
|
||||
const componentsSchema = z.array(componentSchema)
|
||||
|
||||
export async function getAvailableComponents() {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/components`, { agent })
|
||||
const components = await response.json()
|
||||
|
||||
return componentsSchema.parse(components)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to fetch components from ${baseUrl}/api/components.`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,93 @@
|
||||
import path from "path"
|
||||
import { resolveImport } from "@/src/utils/resolve-import"
|
||||
import { cosmiconfig } from "cosmiconfig"
|
||||
import { loadConfig } from "tsconfig-paths"
|
||||
import * as z from "zod"
|
||||
|
||||
export const COMPONENTS_DIR = "./components/ui/"
|
||||
export const UTILS_LOCATION = "@/lib/utils"
|
||||
export const COMPONENT_ALIAS = "@/components/ui/"
|
||||
export const DEFAULT_STYLE = "default"
|
||||
export const DEFAULT_COMPONENTS = "@/components"
|
||||
export const DEFAULT_UTILS = "@/lib/utils"
|
||||
export const DEFAULT_TAILWIND_CSS = "app/globals.css"
|
||||
export const DEFAULT_TAILWIND_CONFIG = "tailwind.config.js"
|
||||
export const DEFAULT_TAILWIND_BASE_COLOR = "slate"
|
||||
|
||||
/**
|
||||
* this is the name of the key we are looking for, the following are the intended values to look for:
|
||||
* - shadcn-ui property in package.json
|
||||
* - .shadcn-uirc file in JSON or YAML format
|
||||
* - .shadcn-uirc.json, .shadcn-uirc.yaml, .shadcn-uirc.yml, .shadcn-uirc.js, or .shadcn-uirc.cjs file
|
||||
* - shadcn-uirc, shadcn-uirc.json, shadcn-uirc.yaml, shadcn-uirc.yml, shadcn-uirc.js or shadcn-uirc.cjs file inside a .config subdirectory
|
||||
* - shadcn-ui.config.js or shadcn-ui.config.cjs CommonJS module exporting an object
|
||||
*/
|
||||
const explorer = cosmiconfig("shadcn-ui")
|
||||
// TODO: Figure out if we want to support all cosmiconfig formats.
|
||||
// A simple components.json file would be nice.
|
||||
const explorer = cosmiconfig("components", {
|
||||
searchPlaces: ["components.json"],
|
||||
})
|
||||
|
||||
const configSchema = z.object({
|
||||
componentsDirInstallation: z.string(),
|
||||
askForDir: z.boolean(),
|
||||
utilsLocation: z.string(),
|
||||
componentDirAlias: z.string(),
|
||||
export const rawConfigSchema = z
|
||||
.object({
|
||||
style: z.string(),
|
||||
rsc: z.coerce.boolean().default(false),
|
||||
tailwind: z.object({
|
||||
config: z.string(),
|
||||
css: z.string(),
|
||||
baseColor: z.string(),
|
||||
cssVariables: z.boolean().default(true),
|
||||
}),
|
||||
aliases: z.object({
|
||||
components: z.string(),
|
||||
utils: z.string(),
|
||||
}),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type RawConfig = z.infer<typeof rawConfigSchema>
|
||||
|
||||
export const configSchema = rawConfigSchema.extend({
|
||||
resolvedPaths: z.object({
|
||||
tailwindConfig: z.string(),
|
||||
tailwindCss: z.string(),
|
||||
utils: z.string(),
|
||||
components: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
export type Config = z.infer<typeof configSchema>
|
||||
|
||||
export async function getCliConfig(): Promise<Config> {
|
||||
const defaultConfig: Config = {
|
||||
componentsDirInstallation: COMPONENTS_DIR,
|
||||
askForDir: true,
|
||||
utilsLocation: UTILS_LOCATION,
|
||||
componentDirAlias: COMPONENT_ALIAS,
|
||||
export async function getConfig(cwd: string) {
|
||||
const config = await getRawConfig(cwd)
|
||||
|
||||
if (!config) {
|
||||
return null
|
||||
}
|
||||
|
||||
const userDefinedConfig = await getConfigFromEverywhere()
|
||||
|
||||
return {
|
||||
...defaultConfig,
|
||||
...userDefinedConfig,
|
||||
askForDir: !userDefinedConfig.componentsDirInstallation,
|
||||
}
|
||||
return await resolveConfigPaths(cwd, config)
|
||||
}
|
||||
|
||||
export async function getConfigFromEverywhere() {
|
||||
export async function resolveConfigPaths(cwd: string, config: RawConfig) {
|
||||
// Read tsconfig.json.
|
||||
const tsConfig = await loadConfig(cwd)
|
||||
|
||||
if (tsConfig.resultType === "failed") {
|
||||
throw new Error(
|
||||
`Failed to load tsconfig.json. ${tsConfig.message ?? ""}`.trim()
|
||||
)
|
||||
}
|
||||
|
||||
return configSchema.parse({
|
||||
...config,
|
||||
resolvedPaths: {
|
||||
tailwindConfig: path.resolve(cwd, config.tailwind.config),
|
||||
tailwindCss: path.resolve(cwd, config.tailwind.css),
|
||||
utils: await resolveImport(config.aliases["utils"], tsConfig),
|
||||
components: await resolveImport(config.aliases["components"], tsConfig),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function getRawConfig(cwd: string): Promise<RawConfig | null> {
|
||||
try {
|
||||
const configResult = await explorer.search()
|
||||
const configResult = await explorer.search(cwd)
|
||||
|
||||
if (!configResult) {
|
||||
// we should always return an object so we can then merge with
|
||||
// the base config
|
||||
return {}
|
||||
return null
|
||||
}
|
||||
|
||||
const { config } = configResult
|
||||
|
||||
const parsedConfig = configSchema.partial().parse(config)
|
||||
return parsedConfig
|
||||
} catch (e) {
|
||||
// lets just show the error to the user to aware about the issue, but lets not handle it.
|
||||
console.log(e)
|
||||
return {}
|
||||
return rawConfigSchema.parse(configResult.config)
|
||||
} catch (error) {
|
||||
throw new Error(`Invald configuration found in ${cwd}/components.json.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,37 @@
|
||||
export function getPackageManager() {
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
|
||||
async function fileExists(path: string) {
|
||||
try {
|
||||
await fs.access(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPackageManager(
|
||||
targetDir: string
|
||||
): Promise<"yarn" | "pnpm" | "npm"> {
|
||||
const [yarnLock, npmLock, pnpmLock] = await Promise.all([
|
||||
fileExists(path.resolve(targetDir, "yarn.lock")),
|
||||
fileExists(path.resolve(targetDir, "package-lock.json")),
|
||||
fileExists(path.resolve(targetDir, "pnpm-lock.yaml")),
|
||||
])
|
||||
|
||||
if (yarnLock) {
|
||||
return "yarn"
|
||||
}
|
||||
|
||||
if (pnpmLock) {
|
||||
return "pnpm"
|
||||
}
|
||||
|
||||
if (npmLock) {
|
||||
return "npm"
|
||||
}
|
||||
|
||||
// Match based on used package manager
|
||||
const userAgent = process.env.npm_config_user_agent
|
||||
|
||||
if (!userAgent) {
|
||||
|
||||
16
packages/cli/src/utils/handle-error.ts
Normal file
16
packages/cli/src/utils/handle-error.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { logger } from "@/src/utils/logger"
|
||||
|
||||
export function handleError(error: unknown) {
|
||||
if (typeof error === "string") {
|
||||
logger.error(error)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
logger.error(error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
logger.error("Something went wrong. Please try again.")
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -13,4 +13,7 @@ export const logger = {
|
||||
success(...args: unknown[]) {
|
||||
console.log(chalk.green(...args))
|
||||
},
|
||||
break() {
|
||||
console.log("")
|
||||
},
|
||||
}
|
||||
|
||||
155
packages/cli/src/utils/registry/index.ts
Normal file
155
packages/cli/src/utils/registry/index.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import path from "path"
|
||||
import { Config } from "@/src/utils/get-config"
|
||||
import {
|
||||
registryBaseColorSchema,
|
||||
registryIndexSchema,
|
||||
registryItemWithContentSchema,
|
||||
registryWithContentSchema,
|
||||
stylesSchema,
|
||||
} from "@/src/utils/registry/schema"
|
||||
import { HttpsProxyAgent } from "https-proxy-agent"
|
||||
import fetch from "node-fetch"
|
||||
import * as z from "zod"
|
||||
|
||||
// const baseUrl = process.env.COMPONENTS_REGISTRY_URL ?? "https://ui.shadcn.com"
|
||||
const baseUrl =
|
||||
process.env.COMPONENTS_REGISTRY_URL ??
|
||||
"https://ui-git-feat-minor-updates-shadcn-pro.vercel.app"
|
||||
const agent = process.env.https_proxy
|
||||
? new HttpsProxyAgent(process.env.https_proxy)
|
||||
: undefined
|
||||
|
||||
export async function getRegistryIndex() {
|
||||
try {
|
||||
const [result] = await fetchRegistry(["index.json"])
|
||||
|
||||
return registryIndexSchema.parse(result)
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch components from registry.`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRegistryStyles() {
|
||||
try {
|
||||
const [result] = await fetchRegistry(["styles/index.json"])
|
||||
|
||||
return stylesSchema.parse(result)
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch styles from registry.`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRegistryBaseColors() {
|
||||
return [
|
||||
{
|
||||
name: "slate",
|
||||
label: "Slate",
|
||||
},
|
||||
{
|
||||
name: "gray",
|
||||
label: "Gray",
|
||||
},
|
||||
{
|
||||
name: "zinc",
|
||||
label: "Zinc",
|
||||
},
|
||||
{
|
||||
name: "neutral",
|
||||
label: "Neutral",
|
||||
},
|
||||
{
|
||||
name: "stone",
|
||||
label: "Stone",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export async function getRegistryBaseColor(baseColor: string) {
|
||||
try {
|
||||
const [result] = await fetchRegistry([`colors/${baseColor}.json`])
|
||||
|
||||
return registryBaseColorSchema.parse(result)
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch base color from registry.`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveTree(
|
||||
index: z.infer<typeof registryIndexSchema>,
|
||||
names: string[]
|
||||
) {
|
||||
const tree: z.infer<typeof registryIndexSchema> = []
|
||||
|
||||
for (const name of names) {
|
||||
const entry = index.find((entry) => entry.name === name)
|
||||
|
||||
if (!entry) {
|
||||
continue
|
||||
}
|
||||
|
||||
tree.push(entry)
|
||||
|
||||
if (entry.registryDependencies) {
|
||||
const dependencies = await resolveTree(index, entry.registryDependencies)
|
||||
tree.push(...dependencies)
|
||||
}
|
||||
}
|
||||
|
||||
return tree.filter(
|
||||
(component, index, self) =>
|
||||
self.findIndex((c) => c.name === component.name) === index
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchTree(
|
||||
style: string,
|
||||
tree: z.infer<typeof registryIndexSchema>
|
||||
) {
|
||||
try {
|
||||
const paths = tree.map((item) => `styles/${style}/${item.name}.json`)
|
||||
const result = await fetchRegistry(paths)
|
||||
|
||||
return registryWithContentSchema.parse(result)
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch tree from registry.`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getItemTargetPath(
|
||||
config: Config,
|
||||
item: Pick<z.infer<typeof registryItemWithContentSchema>, "type">,
|
||||
override?: string
|
||||
) {
|
||||
// Allow overrides for all items but ui.
|
||||
if (override && item.type !== "components:ui") {
|
||||
return override
|
||||
}
|
||||
|
||||
const [parent, type] = item.type.split(":")
|
||||
if (!(parent in config.resolvedPaths)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return path.join(
|
||||
config.resolvedPaths[parent as keyof typeof config.resolvedPaths],
|
||||
type
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchRegistry(paths: string[]) {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const response = await fetch(`${baseUrl}/registry/${path}`, {
|
||||
agent,
|
||||
})
|
||||
return await response.json()
|
||||
})
|
||||
)
|
||||
|
||||
return results
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
throw new Error(`Failed to fetch registry from ${baseUrl}.`)
|
||||
}
|
||||
}
|
||||
43
packages/cli/src/utils/registry/schema.ts
Normal file
43
packages/cli/src/utils/registry/schema.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as z from "zod"
|
||||
|
||||
// TODO: Extract this to a shared package.
|
||||
export const registryItemSchema = z.object({
|
||||
name: z.string(),
|
||||
dependencies: z.array(z.string()).optional(),
|
||||
registryDependencies: z.array(z.string()).optional(),
|
||||
files: z.array(z.string()),
|
||||
type: z.enum(["components:ui", "components:component", "components:example"]),
|
||||
})
|
||||
|
||||
export const registryIndexSchema = z.array(registryItemSchema)
|
||||
|
||||
export const registryItemWithContentSchema = registryItemSchema.extend({
|
||||
files: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
content: z.string(),
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
export const registryWithContentSchema = z.array(registryItemWithContentSchema)
|
||||
|
||||
export const stylesSchema = z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
})
|
||||
)
|
||||
|
||||
export const registryBaseColorSchema = z.object({
|
||||
inlineColors: z.object({
|
||||
light: z.record(z.string(), z.string()),
|
||||
dark: z.record(z.string(), z.string()),
|
||||
}),
|
||||
cssVars: z.object({
|
||||
light: z.record(z.string(), z.string()),
|
||||
dark: z.record(z.string(), z.string()),
|
||||
}),
|
||||
inlineColorsTemplate: z.string(),
|
||||
cssVarsTemplate: z.string(),
|
||||
})
|
||||
13
packages/cli/src/utils/resolve-import.ts
Normal file
13
packages/cli/src/utils/resolve-import.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createMatchPath, type ConfigLoaderSuccessResult } from "tsconfig-paths"
|
||||
|
||||
export async function resolveImport(
|
||||
importPath: string,
|
||||
config: Pick<ConfigLoaderSuccessResult, "absoluteBaseUrl" | "paths">
|
||||
) {
|
||||
return createMatchPath(config.absoluteBaseUrl, config.paths)(
|
||||
importPath,
|
||||
undefined,
|
||||
() => true,
|
||||
[".ts", ".tsx"]
|
||||
)
|
||||
}
|
||||
@@ -1,86 +1,4 @@
|
||||
export const STYLES = `@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
|
||||
--destructive: 0 100% 50%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 215 20.2% 65.1%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 224 71% 4%;
|
||||
--foreground: 213 31% 91%;
|
||||
|
||||
--muted: 223 47% 11%;
|
||||
--muted-foreground: 215.4 16.3% 56.9%;
|
||||
|
||||
--popover: 224 71% 4%;
|
||||
--popover-foreground: 215 20.2% 65.1%;
|
||||
|
||||
--card: 224 71% 4%;
|
||||
--card-foreground: 213 31% 91%;
|
||||
|
||||
--border: 216 34% 17%;
|
||||
--input: 216 34% 17%;
|
||||
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 1.2%;
|
||||
|
||||
--secondary: 222.2 47.4% 11.2%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
|
||||
--accent: 216 34% 17%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--ring: 216 34% 17%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}`
|
||||
|
||||
export const UTILS = `import { ClassValue, clsx } from "clsx"
|
||||
export const UTILS = `import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
@@ -95,6 +13,44 @@ module.exports = {
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}`
|
||||
|
||||
export const TAILWIND_CONFIG_WITH_VARIABLES = `/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
|
||||
51
packages/cli/src/utils/transformers/index.ts
Normal file
51
packages/cli/src/utils/transformers/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { promises as fs } from "fs"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { Config } from "@/src/utils/get-config"
|
||||
import { registryBaseColorSchema } from "@/src/utils/registry/schema"
|
||||
import { transformCssVars } from "@/src/utils/transformers/transform-css-vars"
|
||||
import { transformImport } from "@/src/utils/transformers/transform-import"
|
||||
import { transformRsc } from "@/src/utils/transformers/transform-rsc"
|
||||
import { Project, ScriptKind, type SourceFile } from "ts-morph"
|
||||
import * as z from "zod"
|
||||
|
||||
export type TransformOpts = {
|
||||
filename: string
|
||||
raw: string
|
||||
config: Config
|
||||
baseColor?: z.infer<typeof registryBaseColorSchema>
|
||||
}
|
||||
|
||||
export type Transformer = (
|
||||
opts: TransformOpts & {
|
||||
sourceFile: SourceFile
|
||||
}
|
||||
) => Promise<SourceFile>
|
||||
|
||||
const transformers: Transformer[] = [
|
||||
transformImport,
|
||||
transformRsc,
|
||||
transformCssVars,
|
||||
]
|
||||
|
||||
const project = new Project({
|
||||
compilerOptions: {},
|
||||
})
|
||||
|
||||
async function createTempSourceFile(filename: string) {
|
||||
const dir = await fs.mkdtemp(path.join(tmpdir(), "shadcn-"))
|
||||
return path.join(dir, filename)
|
||||
}
|
||||
|
||||
export async function transform(opts: TransformOpts) {
|
||||
const tempFile = await createTempSourceFile(opts.filename)
|
||||
const sourceFile = project.createSourceFile(tempFile, opts.raw, {
|
||||
scriptKind: ScriptKind.TSX,
|
||||
})
|
||||
|
||||
for (const transformer of transformers) {
|
||||
transformer({ sourceFile, ...opts })
|
||||
}
|
||||
|
||||
return sourceFile.getFullText()
|
||||
}
|
||||
182
packages/cli/src/utils/transformers/transform-css-vars.ts
Normal file
182
packages/cli/src/utils/transformers/transform-css-vars.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { registryBaseColorSchema } from "@/src/utils/registry/schema"
|
||||
import { Transformer } from "@/src/utils/transformers"
|
||||
import { ScriptKind, SyntaxKind } from "ts-morph"
|
||||
import * as z from "zod"
|
||||
|
||||
export const transformCssVars: Transformer = async ({
|
||||
sourceFile,
|
||||
config,
|
||||
baseColor,
|
||||
}) => {
|
||||
// No transform if using css variables.
|
||||
if (config.tailwind?.cssVariables || !baseColor?.inlineColors) {
|
||||
return sourceFile
|
||||
}
|
||||
|
||||
// Find jsx attributes with the name className.
|
||||
// const openingElements = sourceFile.getDescendantsOfKind(SyntaxKind.JsxElement)
|
||||
// console.log(openingElements)
|
||||
// const jsxAttributes = sourceFile
|
||||
// .getDescendantsOfKind(SyntaxKind.JsxAttribute)
|
||||
// .filter((node) => node.getName() === "className")
|
||||
|
||||
// for (const jsxAttribute of jsxAttributes) {
|
||||
// const value = jsxAttribute.getInitializer()?.getText()
|
||||
// if (value) {
|
||||
// const valueWithColorMapping = applyColorMapping(
|
||||
// value.replace(/"/g, ""),
|
||||
// baseColor.inlineColors
|
||||
// )
|
||||
// jsxAttribute.setInitializer(`"${valueWithColorMapping}"`)
|
||||
// }
|
||||
// }
|
||||
sourceFile.getDescendantsOfKind(SyntaxKind.StringLiteral).forEach((node) => {
|
||||
const value = node.getText()
|
||||
if (value) {
|
||||
const valueWithColorMapping = applyColorMapping(
|
||||
value.replace(/"/g, ""),
|
||||
baseColor.inlineColors
|
||||
)
|
||||
node.replaceWithText(`"${valueWithColorMapping.trim()}"`)
|
||||
}
|
||||
})
|
||||
|
||||
return sourceFile
|
||||
}
|
||||
|
||||
// export default function transformer(file: FileInfo, api: API) {
|
||||
// const j = api.jscodeshift.withParser("tsx")
|
||||
|
||||
// // Replace bg-background with "bg-white dark:bg-slate-950"
|
||||
// const $j = j(file.source)
|
||||
// return $j
|
||||
// .find(j.JSXAttribute, {
|
||||
// name: {
|
||||
// name: "className",
|
||||
// },
|
||||
// })
|
||||
// .forEach((path) => {
|
||||
// const { node } = path
|
||||
// if (node?.value?.type) {
|
||||
// if (node.value.type === "StringLiteral") {
|
||||
// node.value.value = applyColorMapping(node.value.value)
|
||||
// console.log(node.value.value)
|
||||
// }
|
||||
|
||||
// if (
|
||||
// node.value.type === "JSXExpressionContainer" &&
|
||||
// node.value.expression.type === "CallExpression"
|
||||
// ) {
|
||||
// const callee = node.value.expression.callee
|
||||
// if (callee.type === "Identifier" && callee.name === "cn") {
|
||||
// node.value.expression.arguments.forEach((arg) => {
|
||||
// if (arg.type === "StringLiteral") {
|
||||
// arg.value = applyColorMapping(arg.value)
|
||||
// }
|
||||
|
||||
// if (
|
||||
// arg.type === "LogicalExpression" &&
|
||||
// arg.right.type === "StringLiteral"
|
||||
// ) {
|
||||
// arg.right.value = applyColorMapping(arg.right.value)
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
// .toSource()
|
||||
// }
|
||||
|
||||
// // export function splitClassName(input: string): (string | null)[] {
|
||||
// // const parts = input.split(":")
|
||||
// // const classNames = parts.map((part) => {
|
||||
// // const match = part.match(/^\[?(.+)\]$/)
|
||||
// // if (match) {
|
||||
// // return match[1]
|
||||
// // } else {
|
||||
// // return null
|
||||
// // }
|
||||
// // })
|
||||
|
||||
// // return classNames
|
||||
// // }
|
||||
|
||||
// Splits a className into variant-name-alpha.
|
||||
// eg. hover:bg-primary-100 -> [hover, bg-primary, 100]
|
||||
export function splitClassName(className: string): (string | null)[] {
|
||||
if (!className.includes("/") && !className.includes(":")) {
|
||||
return [null, className, null]
|
||||
}
|
||||
|
||||
const parts: (string | null)[] = []
|
||||
// First we split to find the alpha.
|
||||
let [rest, alpha] = className.split("/")
|
||||
|
||||
// Check if rest has a colon.
|
||||
if (!rest.includes(":")) {
|
||||
return [null, rest, alpha]
|
||||
}
|
||||
|
||||
// Next we split the rest by the colon.
|
||||
const split = rest.split(":")
|
||||
|
||||
// We take the last item from the split as the name.
|
||||
const name = split.pop()
|
||||
|
||||
// We glue back the rest of the split.
|
||||
const variant = split.join(":")
|
||||
|
||||
// Finally we push the variant, name and alpha.
|
||||
parts.push(variant ?? null, name ?? null, alpha ?? null)
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
const PREFIXES = ["bg-", "text-", "border-", "ring-offset-", "ring-"]
|
||||
|
||||
export function applyColorMapping(
|
||||
input: string,
|
||||
mapping: z.infer<typeof registryBaseColorSchema>["inlineColors"]
|
||||
) {
|
||||
// Handle border classes.
|
||||
if (input.includes(" border ")) {
|
||||
input = input.replace(" border ", " border border-border ")
|
||||
}
|
||||
|
||||
// Build color mappings.
|
||||
const classNames = input.split(" ")
|
||||
const lightMode: string[] = []
|
||||
const darkMode: string[] = []
|
||||
for (let className of classNames) {
|
||||
const [variant, value, modifier] = splitClassName(className)
|
||||
const prefix = PREFIXES.find((prefix) => value?.startsWith(prefix))
|
||||
if (!prefix) {
|
||||
if (!lightMode.includes(className)) {
|
||||
lightMode.push(className)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const needle = value?.replace(prefix, "")
|
||||
if (needle && needle in mapping.light) {
|
||||
lightMode.push(
|
||||
[variant, `${prefix}${mapping.light[needle]}`, modifier]
|
||||
.filter(Boolean)
|
||||
.join(":")
|
||||
)
|
||||
darkMode.push(
|
||||
["dark", variant, `${prefix}${mapping.dark[needle]}`, modifier]
|
||||
.filter(Boolean)
|
||||
.join(":")
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!lightMode.includes(className)) {
|
||||
lightMode.push(className)
|
||||
}
|
||||
}
|
||||
|
||||
return lightMode.join(" ") + " " + darkMode.join(" ").trim()
|
||||
}
|
||||
32
packages/cli/src/utils/transformers/transform-import.ts
Normal file
32
packages/cli/src/utils/transformers/transform-import.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Transformer } from "@/src/utils/transformers"
|
||||
|
||||
export const transformImport: Transformer = async ({ sourceFile, config }) => {
|
||||
const importDeclarations = sourceFile.getImportDeclarations()
|
||||
|
||||
for (const importDeclaration of importDeclarations) {
|
||||
const moduleSpecifier = importDeclaration.getModuleSpecifierValue()
|
||||
|
||||
// Replace @/registry/[style] with the components alias.
|
||||
if (moduleSpecifier.startsWith("@/registry/")) {
|
||||
importDeclaration.setModuleSpecifier(
|
||||
moduleSpecifier.replace(
|
||||
/^@\/registry\/[^/]+/,
|
||||
config.aliases.components
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Replace `import { cn } from "@/lib/utils"`
|
||||
if (moduleSpecifier == "@/lib/utils") {
|
||||
const namedImports = importDeclaration.getNamedImports()
|
||||
const cnImport = namedImports.find((i) => i.getName() === "cn")
|
||||
if (cnImport) {
|
||||
importDeclaration.setModuleSpecifier(
|
||||
moduleSpecifier.replace(/^@\/lib\/utils/, config.aliases.utils)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sourceFile
|
||||
}
|
||||
16
packages/cli/src/utils/transformers/transform-rsc.ts
Normal file
16
packages/cli/src/utils/transformers/transform-rsc.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Transformer } from "@/src/utils/transformers"
|
||||
import { SyntaxKind } from "ts-morph"
|
||||
|
||||
export const transformRsc: Transformer = async ({ sourceFile, config }) => {
|
||||
if (config.rsc) {
|
||||
return sourceFile
|
||||
}
|
||||
|
||||
// Remove "use client" from the top of the file.
|
||||
const first = sourceFile.getFirstChildByKind(SyntaxKind.ExpressionStatement)
|
||||
if (first?.getText() === `"use client"`) {
|
||||
first.remove()
|
||||
}
|
||||
|
||||
return sourceFile
|
||||
}
|
||||
Reference in New Issue
Block a user