From ea5c63a250a225a3f44ce72ecd3e191d8072ef90 Mon Sep 17 00:00:00 2001 From: Mukund Shah <39938037+mukundshah@users.noreply.github.com> Date: Mon, 28 Aug 2023 10:50:32 +0545 Subject: [PATCH] feat: add cli --- package.json | 2 +- packages/cli/package.json | 7 +- packages/cli/src/commands/add.ts | 178 +++++++++++ packages/cli/src/commands/diff.ts | 195 ++++++++++++ packages/cli/src/commands/init.ts | 288 ++++++++++++++++++ packages/cli/src/index.ts | 13 +- packages/cli/src/utils/get-config.ts | 96 ++++++ packages/cli/src/utils/get-package-info.ts | 9 + packages/cli/src/utils/get-package-manager.ts | 16 + packages/cli/src/utils/get-project-info.ts | 45 +++ packages/cli/src/utils/handle-error.ts | 17 ++ packages/cli/src/utils/logger.ts | 19 ++ packages/cli/src/utils/registry/index.ts | 155 ++++++++++ packages/cli/src/utils/registry/schema.ts | 43 +++ packages/cli/src/utils/resolve-import.ts | 13 + packages/cli/src/utils/templates.ts | 227 ++++++++++++++ packages/cli/src/utils/transformers/index.ts | 51 ++++ .../utils/transformers/transform-css-vars.ts | 103 +++++++ .../utils/transformers/transform-import.ts | 32 ++ .../src/utils/transformers/transform-sfc.ts | 0 packages/cli/tsconfig.json | 13 + tsconfig.json | 20 ++ 22 files changed, 1538 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/utils/get-config.ts create mode 100644 packages/cli/src/utils/get-package-info.ts create mode 100644 packages/cli/src/utils/get-package-manager.ts create mode 100644 packages/cli/src/utils/get-project-info.ts create mode 100644 packages/cli/src/utils/handle-error.ts create mode 100644 packages/cli/src/utils/logger.ts create mode 100644 packages/cli/src/utils/registry/index.ts create mode 100644 packages/cli/src/utils/registry/schema.ts create mode 100644 packages/cli/src/utils/resolve-import.ts create mode 100644 packages/cli/src/utils/templates.ts create mode 100644 packages/cli/src/utils/transformers/index.ts create mode 100644 packages/cli/src/utils/transformers/transform-css-vars.ts create mode 100644 packages/cli/src/utils/transformers/transform-import.ts create mode 100644 packages/cli/src/utils/transformers/transform-sfc.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 tsconfig.json diff --git a/package.json b/package.json index 60869013..56d3dccf 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "pnpm": "^8.6.12", "simple-git-hooks": "^2.9.0", "turbo": "^1.10.13", - "typescript": "^5.0.2", + "typescript": "^5.2.2", "vitest": "^0.34.3" }, "commitlint": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 5dd52032..eb01b897 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,7 +35,7 @@ "clean": "rimraf dist && rimraf components", "lint": "eslint .", "lint:fix": "eslint --fix .", - "start:dev": "cross-env COMPONENTS_REGISTRY_URL=http://localhost:3000 node dist/index.js", + "start:dev": "cross-env COMPONENTS_REGISTRY_URL=http://localhost:3001 node dist/index.js", "start": "node dist/index.js", "release": "changeset version", "pub:beta": "pnpm build && pnpm publish --no-git-checks --access public --tag beta", @@ -46,6 +46,9 @@ }, "dependencies": { "@antfu/ni": "^0.21.6", + "@babel/core": "^7.22.11", + "@babel/parser": "^7.22.11", + "@babel/plugin-transform-typescript": "^7.22.11", "chalk": "5.3.0", "commander": "^11.0.0", "cosmiconfig": "^8.2.0", @@ -63,10 +66,12 @@ "zod": "^3.22.2" }, "devDependencies": { + "@types/babel__core": "^7.20.1", "@types/diff": "^5.0.3", "@types/fs-extra": "^11.0.1", "@types/lodash.template": "^4.5.1", "@types/prompts": "^2.4.4", + "@vitest/ui": "^0.34.3", "rimraf": "^5.0.1", "tsup": "^7.2.0", "type-fest": "^4.3.0", diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index e69de29b..be3494db 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -0,0 +1,178 @@ +import { existsSync, promises as fs } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +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' +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' + +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 ', + 'the working directory. defaults to the current directory.', + process.cwd(), + ) + .option('-p, --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: 'autocompleteMultiselect', + 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) { + let filePath = path.resolve(targetDir, file.name) + + // Run transformers. + const content = await transform({ + filename: file.name, + raw: file.content, + config, + baseColor, + }) + + // if (!config.tsx) + // filePath = filePath.replace(/\.tsx$/, '.jsx') + + 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) + } + }) diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts index e69de29b..a4fb5f4c 100644 --- a/packages/cli/src/commands/diff.ts +++ b/packages/cli/src/commands/diff.ts @@ -0,0 +1,195 @@ +import { existsSync, promises as fs } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import chalk from 'chalk' +import { Command } from 'commander' +import { type Change, diffLines } from 'diff' +import * as z from 'zod' +import type { Config } from '@/src/utils/get-config' +import { 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 type { registryIndexSchema } from '@/src/utils/registry/schema' +import { transform } from '@/src/utils/transformers' + +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 ', + '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 ')} 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}`) + printDiff(change.patch) + logger.info('') + } + } + catch (error) { + handleError(error) + } + }) + +async function diffComponent( + component: z.infer[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 as string, fileContent) + if (patch.length > 1) { + changes.push({ + file: file.name, + filePath, + patch, + }) + } + } + } + + return changes +} + +// TODO: Does is it need to 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) + } + }) +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index e69de29b..35d1a6ee 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -0,0 +1,288 @@ +import { existsSync, promises as fs } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import chalk from 'chalk' +import { Command } from 'commander' +import { execa } from 'execa' +import template from 'lodash.template' +import ora from 'ora' +import prompts from 'prompts' +import * as z from 'zod' +import * as templates from '@/src/utils/templates' +import { + getRegistryBaseColor, + getRegistryBaseColors, + getRegistryStyles, +} from '@/src/utils/registry' +import { logger } from '@/src/utils/logger' +import { handleError } from '@/src/utils/handle-error' +import { getPackageManager } from '@/src/utils/get-package-manager' +import { + type Config, + DEFAULT_COMPONENTS, + DEFAULT_TAILWIND_CONFIG, + DEFAULT_TAILWIND_CSS, + DEFAULT_TAILWIND_CSS_NUXT, + DEFAULT_UTILS, + getConfig, + rawConfigSchema, + resolveConfigPaths, +} from '@/src/utils/get-config' + +const PROJECT_DEPENDENCIES = { + base: [ + 'tailwindcss-animate', + 'class-variance-authority', + 'clsx', + 'tailwind-merge', + ], + vue: [ + 'tailwindcss', + 'postcss', + 'autoprefixer', + ], + nuxt: [ + '@nuxtjs/tailwindcss', + ], +} + +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 ', + '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: 'toggle', + name: 'typescript', + message: `Would you like to use ${highlight('TypeScript')} (recommended)?`, + initial: defaultConfig?.typescript ?? true, + active: 'yes', + inactive: 'no', + }, + { + type: 'select', + name: 'framework', + message: `Which ${highlight('framework')} are you using?`, + choices: [ + {title: 'Nuxt', value: 'nuxt'}, + {title: 'Vite + Vue', value: 'vue'}, + ], + }, + { + 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('Tailwind CSS')} file?`, + initial: (prev,values) => defaultConfig?.tailwind.css ?? values.framework === 'nuxt' ? DEFAULT_TAILWIND_CSS_NUXT : DEFAULT_TAILWIND_CSS + }, + { + type: 'toggle', + name: 'tailwindCssVariables', + message: `Would you like 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, + }, + ]) + + const config = rawConfigSchema.parse({ + // $schema: 'https://ui.shadcn.com/schema.json', + style: options.style, + typescript: options.typescript, + framework: options.framework, + tailwind: { + config: options.tailwindConfig, + css: options.tailwindCss, + baseColor: options.tailwindBaseColor, + cssVariables: options.tailwindCssVariables, + }, + 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 }) + } + + const extension = config.typescript ? 'ts' : 'js' + + // Write tailwind config. + await fs.writeFile( + config.resolvedPaths.tailwindConfig, + config.tailwind.cssVariables + ? template(config.framework === 'nuxt' ? templates.NUXT_TAILWIND_CONFIG_WITH_VARIABLES : templates.TAILWIND_CONFIG_WITH_VARIABLES)({ extension }) + : template(config.framework === 'nuxt' ? templates.NUXT_TAILWIND_CONFIG : templates.TAILWIND_CONFIG)({ extension }), + '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}.${extension}`, + extension === 'ts' ? templates.UTILS : templates.UTILS_JS, + '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.base.concat( + config.framework === 'nuxt' ? PROJECT_DEPENDENCIES.nuxt : PROJECT_DEPENDENCIES.vue, + ).concat( + config.style === 'new-york' ? [] : ['lucide-vue-next'], + ) + + await execa( + packageManager, + [packageManager === 'npm' ? 'install' : 'add', ...deps], + { + cwd, + }, + ) + dependenciesSpinner?.succeed() +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7627ed8f..ab24ad04 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,20 +3,29 @@ import process from 'node:process' import { Command } from 'commander' +import { add } from '@/src/commands/add' +import { diff } from '@/src/commands/diff' +import { init } from '@/src/commands/init' +import { getPackageInfo } from '@/src/utils/get-package-info' + process.on('SIGINT', () => process.exit(0)) process.on('SIGTERM', () => process.exit(0)) async function main() { + const packageInfo = await getPackageInfo() + const program = new Command() .name('shadcn-vue') .description('add components and dependencies to your project') .version( - '1.0.0', + packageInfo.version || '1.0.0', '-v, --version', 'display the version number', ) + program.addCommand(init).addCommand(add).addCommand(diff) + program.parse() } -await main() +main() diff --git a/packages/cli/src/utils/get-config.ts b/packages/cli/src/utils/get-config.ts new file mode 100644 index 00000000..c2c5a63c --- /dev/null +++ b/packages/cli/src/utils/get-config.ts @@ -0,0 +1,96 @@ +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 DEFAULT_STYLE = 'default' +export const DEFAULT_COMPONENTS = '@/components' +export const DEFAULT_UTILS = '@/utils' +export const DEFAULT_TAILWIND_CSS = 'src/style.css' +export const DEFAULT_TAILWIND_CSS_NUXT = 'assets/style/tailwind.css' +export const DEFAULT_TAILWIND_CONFIG = 'tailwind.config.js' +export const DEFAULT_TAILWIND_BASE_COLOR = 'slate' + +// 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'], +}) + +export const rawConfigSchema = z + .object({ + $schema: z.string().optional(), + style: z.string(), + typescript: z.boolean().default(false), + tailwind: z.object({ + config: z.string(), + css: z.string(), + baseColor: z.string(), + cssVariables: z.boolean().default(true), + }), + framework: z.string(), + aliases: z.object({ + components: z.string(), + utils: z.string(), + }) + }) + .strict() + +export type RawConfig = z.infer + +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 + +export async function getConfig(cwd: string) { + const config = await getRawConfig(cwd) + + if (!config) + return null + + return await resolveConfigPaths(cwd, config) +} + +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 { + try { + const configResult = await explorer.search(cwd) + + if (!configResult) + return null + + return rawConfigSchema.parse(configResult.config) + } + catch (error) { + throw new Error(`Invalid configuration found in ${cwd}/components.json.`) + } +} diff --git a/packages/cli/src/utils/get-package-info.ts b/packages/cli/src/utils/get-package-info.ts new file mode 100644 index 00000000..12d46968 --- /dev/null +++ b/packages/cli/src/utils/get-package-info.ts @@ -0,0 +1,9 @@ +import path from 'node:path' +import fs from 'fs-extra' +import { type PackageJson } from 'type-fest' + +export function getPackageInfo() { + const packageJsonPath = path.join('package.json') + + return fs.readJSONSync(packageJsonPath) as PackageJson +} diff --git a/packages/cli/src/utils/get-package-manager.ts b/packages/cli/src/utils/get-package-manager.ts new file mode 100644 index 00000000..1361bb5b --- /dev/null +++ b/packages/cli/src/utils/get-package-manager.ts @@ -0,0 +1,16 @@ +import { detect } from '@antfu/ni' + +export async function getPackageManager( + targetDir: string, +): Promise<'yarn' | 'pnpm' | 'bun' | 'npm'> { + const packageManager = await detect({ programmatic: true, cwd: targetDir }) + + if (packageManager === 'yarn@berry') + return 'yarn' + if (packageManager === 'pnpm@6') + return 'pnpm' + if (packageManager === 'bun') + return 'bun' + + return packageManager ?? 'npm' +} diff --git a/packages/cli/src/utils/get-project-info.ts b/packages/cli/src/utils/get-project-info.ts new file mode 100644 index 00000000..3b08691e --- /dev/null +++ b/packages/cli/src/utils/get-project-info.ts @@ -0,0 +1,45 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' +import fs from 'fs-extra' + +export async function getProjectInfo() { + const info = { + tsconfig: null, + isNuxt: false, + isVueVite: false, + srcDir: false, + componentsUiDir: false, + srcComponentsUiDir: false, + } + + try { + const tsconfig = await getTsConfig() + + return { + tsconfig, + isNuxt: existsSync(path.resolve('./nuxt.config.js')) || existsSync(path.resolve('./nuxt.config.ts')), + isVueVite: existsSync(path.resolve('./vite.config.js')) || existsSync(path.resolve('./vite.config.ts')), + srcDir: existsSync(path.resolve('./src')), + srcComponentsUiDir: existsSync(path.resolve('./src/components/ui')), + componentsUiDir: existsSync(path.resolve('./components/ui')), + } + } + catch (error) { + return info + } +} + +export async function getTsConfig() { + try { + const tsconfigPath = path.join('tsconfig.json') + const tsconfig = await fs.readJSON(tsconfigPath) + + if (!tsconfig) + throw new Error('tsconfig.json is missing') + + return tsconfig + } + catch (error) { + return null + } +} diff --git a/packages/cli/src/utils/handle-error.ts b/packages/cli/src/utils/handle-error.ts new file mode 100644 index 00000000..b3804170 --- /dev/null +++ b/packages/cli/src/utils/handle-error.ts @@ -0,0 +1,17 @@ +// import { logger } from '@/src/utils/logger' +import {consola} from 'consola' + +export function handleError(error: unknown) { + if (typeof error === 'string') { + consola.error(error) + process.exit(1) + } + + if (error instanceof Error) { + consola.error(error.message) + process.exit(1) + } + + consola.error('Something went wrong. Please try again.') + process.exit(1) +} diff --git a/packages/cli/src/utils/logger.ts b/packages/cli/src/utils/logger.ts new file mode 100644 index 00000000..a18b7b5d --- /dev/null +++ b/packages/cli/src/utils/logger.ts @@ -0,0 +1,19 @@ +import chalk from "chalk" + +export const logger = { + error(...args: unknown[]) { + console.log(chalk.red(...args)) + }, + warn(...args: unknown[]) { + console.log(chalk.yellow(...args)) + }, + info(...args: unknown[]) { + console.log(chalk.cyan(...args)) + }, + success(...args: unknown[]) { + console.log(chalk.green(...args)) + }, + break() { + console.log("") + }, +} diff --git a/packages/cli/src/utils/registry/index.ts b/packages/cli/src/utils/registry/index.ts new file mode 100644 index 00000000..7ca23376 --- /dev/null +++ b/packages/cli/src/utils/registry/index.ts @@ -0,0 +1,155 @@ +import path from 'node:path' +import { HttpsProxyAgent } from 'https-proxy-agent' +import fetch from 'node-fetch' +import type * as z from 'zod' +import { + registryBaseColorSchema, + registryIndexSchema, + registryWithContentSchema, + stylesSchema, +} from '@/src/utils/registry/schema' +import type { registryItemWithContentSchema } from '@/src/utils/registry/schema' +import type { Config } from '@/src/utils/get-config' + +const baseUrl = process.env.COMPONENTS_REGISTRY_URL ?? 'https://ui.shadcn.com' +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 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, + names: string[], +) { + const tree: z.infer = [] + + 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, +) { + 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 function getItemTargetPath( + config: Config, + item: Pick, '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) { + // eslint-disable-next-line no-console + console.log(error) + throw new Error(`Failed to fetch registry from ${baseUrl}.`) + } +} diff --git a/packages/cli/src/utils/registry/schema.ts b/packages/cli/src/utils/registry/schema.ts new file mode 100644 index 00000000..8b276326 --- /dev/null +++ b/packages/cli/src/utils/registry/schema.ts @@ -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(), +}) diff --git a/packages/cli/src/utils/resolve-import.ts b/packages/cli/src/utils/resolve-import.ts new file mode 100644 index 00000000..cc52b646 --- /dev/null +++ b/packages/cli/src/utils/resolve-import.ts @@ -0,0 +1,13 @@ +import { type ConfigLoaderSuccessResult, createMatchPath } from 'tsconfig-paths' + +export function resolveImport( + importPath: string, + config: Pick, +) { + return createMatchPath(config.absoluteBaseUrl, config.paths)( + importPath, + undefined, + () => true, + ['.ts', '.tsx'], + ) +} diff --git a/packages/cli/src/utils/templates.ts b/packages/cli/src/utils/templates.ts new file mode 100644 index 00000000..657f86bd --- /dev/null +++ b/packages/cli/src/utils/templates.ts @@ -0,0 +1,227 @@ +export const UTILS = `import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} +` + +export const UTILS_JS = `import { clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs) { + return twMerge(clsx(inputs)) +} +` + +export const TAILWIND_CONFIG = `/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ["class"], + content: [ + "./index.html", + "./src/**/*.{vue,js,ts,jsx,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: [ + "./index.html", + "./src/**/*.{vue,js,ts,jsx,tsx}", + ], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + 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 NUXT_TAILWIND_CONFIG = `/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ["class"], + 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 NUXT_TAILWIND_CONFIG_WITH_VARIABLES = `/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ["class"], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + 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")], +}` diff --git a/packages/cli/src/utils/transformers/index.ts b/packages/cli/src/utils/transformers/index.ts new file mode 100644 index 00000000..f7d271c7 --- /dev/null +++ b/packages/cli/src/utils/transformers/index.ts @@ -0,0 +1,51 @@ +import { promises as fs } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { Project, ScriptKind, type SourceFile } from 'ts-morph' +import type * as z from 'zod' +import type { Config } from '@/src/utils/get-config' +import type { registryBaseColorSchema } from '@/src/utils/registry/schema' +import { transformCssVars } from '@/src/utils/transformers/transform-css-vars' +import { transformImport } from '@/src/utils/transformers/transform-import' + +export interface TransformOpts { + filename: string + raw: string + config: Config + baseColor?: z.infer +} + +export type Transformer = ( + opts: TransformOpts & { + sourceFile: SourceFile + } +) => Promise + +const transformers: Transformer[] = [ + transformImport, + 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 await transformJsx({ + // sourceFile, + // ...opts, + // }) +} diff --git a/packages/cli/src/utils/transformers/transform-css-vars.ts b/packages/cli/src/utils/transformers/transform-css-vars.ts new file mode 100644 index 00000000..619df438 --- /dev/null +++ b/packages/cli/src/utils/transformers/transform-css-vars.ts @@ -0,0 +1,103 @@ +import { SyntaxKind } from 'ts-morph' +import type * as z from 'zod' +import type { registryBaseColorSchema } from '@/src/utils/registry/schema' +import type { Transformer } from '@/src/utils/transformers' + +export const transformCssVars: Transformer = async ({ + sourceFile, + config, + baseColor, +}) => { + // No transform if using css variables. + if (config.tailwind?.cssVariables || !baseColor?.inlineColors) + return sourceFile + + 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 +} + +// 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. + const [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['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 (const 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]}`] + .filter(Boolean) + .join(':') + (modifier ? `/${modifier}` : ''), + ) + + darkMode.push( + ['dark', variant, `${prefix}${mapping.dark[needle]}`] + .filter(Boolean) + .join(':') + (modifier ? `/${modifier}` : ''), + ) + continue + } + + if (!lightMode.includes(className)) + lightMode.push(className) + } + + return `${lightMode.join(' ')} ${darkMode.join(' ').trim()}` +} diff --git a/packages/cli/src/utils/transformers/transform-import.ts b/packages/cli/src/utils/transformers/transform-import.ts new file mode 100644 index 00000000..7b274450 --- /dev/null +++ b/packages/cli/src/utils/transformers/transform-import.ts @@ -0,0 +1,32 @@ +import type { 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 +} diff --git a/packages/cli/src/utils/transformers/transform-sfc.ts b/packages/cli/src/utils/transformers/transform-sfc.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 00000000..c1afb195 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "compilerOptions": { + "isolatedModules": false, + "baseUrl": ".", + "paths": { + "@/*": ["./*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..d72a9f3a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Default", + "compilerOptions": { + "composite": false, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "inlineSources": false, + "isolatedModules": true, + "moduleResolution": "node", + "noUnusedLocals": false, + "noUnusedParameters": false, + "preserveWatchOutput": true, + "skipLibCheck": true, + "strict": true + }, + "exclude": ["node_modules"] +}