diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 6b331006..c9573c43 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -1,23 +1,13 @@ -import { existsSync, promises as fs, rmSync } from 'node:fs' +import { existsSync } from 'node:fs' import process from 'node:process' import path from 'pathe' import { consola } from 'consola' import { colors } from 'consola/utils' import { Command } from 'commander' -import ora from 'ora' -import prompts from 'prompts' import { z } from 'zod' -import { addDependency, addDevDependency } from 'nypm' -import { transform } from '@/src/utils/transformers' -import { getConfig } from '@/src/utils/get-config' +import { getProjectInfo } from '../utils/get-project-info' +import frameworksCommands from './frameworks' import { handleError } from '@/src/utils/handle-error' -import { - fetchTree, - getItemTargetPath, - getRegistryBaseColor, - getRegistryIndex, - resolveTree, -} from '@/src/utils/registry' const addOptionsSchema = z.object({ components: z.array(z.string()).optional(), @@ -55,166 +45,20 @@ export const add = new Command() process.exit(1) } - const config = await getConfig(cwd) + // Get the corresponding framework commands + const { isNuxt } = await getProjectInfo() + const framework = isNuxt ? 'nuxt' : 'vue' + const { loadConfig, add } = frameworksCommands[framework] + // Read config + const config = await loadConfig(cwd, options, false) if (!config) { consola.warn(`Configuration is missing. Please run ${colors.green('init')} to create a components.json file.`) - process.exit(1) } - const registryIndex = await getRegistryIndex() - - let selectedComponents = options.all - ? registryIndex.map(entry => entry.name) - : options.components - if (!options.components?.length && !options.all) { - 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, - selected: options.all - ? true - : options.components?.includes(entry.name), - })), - }) - selectedComponents = components - } - - if (!selectedComponents?.length) { - consola.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) { - consola.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 = 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, item.name, file.name)), - ) - - if (existingComponent.length && !options.overwrite) { - if (selectedComponents.includes(item.name)) { - spinner.stop() - const { overwrite } = await prompts({ - type: 'confirm', - name: 'overwrite', - message: `Component ${item.name} already exists. Would you like to overwrite?`, - initial: false, - }) - - if (!overwrite) { - consola.info( - `Skipped ${item.name}. To overwrite, run with the ${colors.green( - '--overwrite', - )} flag.`, - ) - continue - } - - spinner.start(`Installing ${item.name}...`) - } - else { - continue - } - } - - // Install dependencies. - await Promise.allSettled( - [ - item.dependencies?.length && await addDependency(item.dependencies, { - cwd, - silent: true, - }), - item.devDependencies?.length && await addDevDependency(item.devDependencies, { - cwd, - silent: true, - }), - ], - ) - - const componentDir = path.resolve(targetDir, item.name) - if (!existsSync(componentDir)) - await fs.mkdir(componentDir, { recursive: true }) - - const files = item.files.map(file => ({ - ...file, - path: path.resolve( - targetDir, - item.name, - file.name, - ), - })) - - // We need to write original files to disk if we're not using TypeScript. - // Rewrite or delete added files after transformed - if (!config.typescript) { - for (const file of files) - await fs.writeFile(file.path, file.content) - } - - for (const file of files) { - // Run transformers. - const content = await transform({ - filename: file.path, - raw: file.content, - config, - baseColor, - }) - - let filePath = file.path - - if (!config.typescript) { - // remove original .ts file if we're not using TypeScript. - if (file.path.endsWith('.ts')) { - if (existsSync(file.path)) - rmSync(file.path) - } - filePath = file.path.replace(/\.ts$/, '.js') - } - - await fs.writeFile(filePath, content) - } - } - spinner.succeed('Done.') + // Add components + await add(cwd, config, options) } catch (error) { handleError(error) diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts index 8bf17aa9..74d84719 100644 --- a/packages/cli/src/commands/diff.ts +++ b/packages/cli/src/commands/diff.ts @@ -1,22 +1,13 @@ -import { existsSync, promises as fs } from 'node:fs' +import { existsSync } from 'node:fs' import process from 'node:process' import path from 'pathe' import { consola } from 'consola' import { colors } from 'consola/utils' import { Command } from 'commander' -import { type Change, diffLines } from 'diff' import { z } from 'zod' -import type { Config } from '@/src/utils/get-config' -import { getConfig } from '@/src/utils/get-config' +import { getProjectInfo } from '../utils/get-project-info' +import frameworksCommands from './frameworks' import { handleError } from '@/src/utils/handle-error' -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(), @@ -49,148 +40,22 @@ export const diff = new Command() process.exit(1) } - const config = await getConfig(cwd) + // Get the corresponding framework commands + const { isNuxt } = await getProjectInfo() + const framework = isNuxt ? 'nuxt' : 'vue' + const { loadConfig, diff } = frameworksCommands[framework] + + // Load Config + const config = await loadConfig(cwd, options, false) if (!config) { - consola.warn( - `Configuration is missing. Please run ${colors.green( - 'init', - )} to create a components.json file.`, - ) + consola.warn(`Configuration is missing. Please run ${colors.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) { - consola.info('No updates found.') - process.exit(0) - } - - consola.info('The following components have updates available:') - for (const component of componentsWithUpdates) { - consola.info(`- ${component.name}`) - for (const change of component.changes) - consola.info(` - ${change.filePath}`) - } - - consola.log('') - consola.info( - `Run ${colors.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) { - consola.error( - `The component ${colors.green(options.component)} does not exist.`, - ) - process.exit(1) - } - - const changes = await diffComponent(component, config) - - if (!changes.length) { - consola.info(`No updates found for ${options.component}.`) - process.exit(0) - } - - for (const change of changes) { - consola.info(`- ${change.filePath}`) - printDiff(change.patch) - consola.log('') - } + // Run Diff Command + await diff(cwd, config, options) } 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(colors.green(part.value)) - - if (part.removed) - return process.stdout.write(colors.red(part.value)) - - return process.stdout.write(part.value) - } - }) -} diff --git a/packages/cli/src/commands/frameworks/index.ts b/packages/cli/src/commands/frameworks/index.ts new file mode 100644 index 00000000..328156db --- /dev/null +++ b/packages/cli/src/commands/frameworks/index.ts @@ -0,0 +1,21 @@ +import loadConfigVue from './vue/config' +import initVue from './vue/init' +import addVue from './vue/add' +import diffVue from './vue/diff' + +export default { + vue: { + loadConfig: loadConfigVue, + init: initVue, + add: addVue, + diff: diffVue, + }, + // For now we run the same commands for Nuxt as for Vue + // TODO: replace with Nuxt-specific commands when required + nuxt: { + loadConfig: loadConfigVue, + init: initVue, + add: addVue, + diff: diffVue, + }, +} diff --git a/packages/cli/src/commands/frameworks/vue/add.ts b/packages/cli/src/commands/frameworks/vue/add.ts new file mode 100644 index 00000000..45c482c3 --- /dev/null +++ b/packages/cli/src/commands/frameworks/vue/add.ts @@ -0,0 +1,183 @@ +import { existsSync, promises as fs, rmSync } from 'node:fs' +import { addDependency, addDevDependency } from 'nypm' +import { consola } from 'consola' +import path from 'pathe' +import ora from 'ora' +import prompts from 'prompts' +import { colors } from 'consola/utils' +import type { Config } from '../../../utils/get-config' +import { transform } from '@/src/utils/transformers' +import { + fetchTree, + getItemTargetPath, + getRegistryBaseColor, + getRegistryIndex, + resolveTree, +} from '@/src/utils/registry' + +interface AddOptions { + + yes: boolean + overwrite: boolean + cwd: string + all: boolean + path?: string | undefined + components?: string[] | undefined + +} + +export default async function (cwd: string, config: Config, options: AddOptions) { + const registryIndex = await getRegistryIndex() + + let selectedComponents = options.all + ? registryIndex.map(entry => entry.name) + : options.components + if (!options.components?.length && !options.all) { + 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, + selected: options.all + ? true + : options.components?.includes(entry.name), + })), + }) + selectedComponents = components + } + + if (!selectedComponents?.length) { + consola.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) { + consola.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 = 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, item.name, file.name)), + ) + + if (existingComponent.length && !options.overwrite) { + if (selectedComponents.includes(item.name)) { + spinner.stop() + const { overwrite } = await prompts({ + type: 'confirm', + name: 'overwrite', + message: `Component ${item.name} already exists. Would you like to overwrite?`, + initial: false, + }) + + if (!overwrite) { + consola.info( + `Skipped ${item.name}. To overwrite, run with the ${colors.green( + '--overwrite', + )} flag.`, + ) + continue + } + + spinner.start(`Installing ${item.name}...`) + } + else { + continue + } + } + + // Install dependencies. + await Promise.allSettled( + [ + item.dependencies?.length && await addDependency(item.dependencies, { + cwd, + silent: true, + }), + item.devDependencies?.length && await addDevDependency(item.devDependencies, { + cwd, + silent: true, + }), + ], + ) + + const componentDir = path.resolve(targetDir, item.name) + if (!existsSync(componentDir)) + await fs.mkdir(componentDir, { recursive: true }) + + const files = item.files.map(file => ({ + ...file, + path: path.resolve( + targetDir, + item.name, + file.name, + ), + })) + + // We need to write original files to disk if we're not using TypeScript. + // Rewrite or delete added files after transformed + if (!config.typescript) { + for (const file of files) + await fs.writeFile(file.path, file.content) + } + + for (const file of files) { + // Run transformers. + const content = await transform({ + filename: file.path, + raw: file.content, + config, + baseColor, + }) + + let filePath = file.path + + if (!config.typescript) { + // remove original .ts file if we're not using TypeScript. + if (file.path.endsWith('.ts')) { + if (existsSync(file.path)) + rmSync(file.path) + } + filePath = file.path.replace(/\.ts$/, '.js') + } + + await fs.writeFile(filePath, content) + } + } + + spinner.succeed('Done.') +} diff --git a/packages/cli/src/commands/frameworks/vue/config.ts b/packages/cli/src/commands/frameworks/vue/config.ts new file mode 100644 index 00000000..0e484e9d --- /dev/null +++ b/packages/cli/src/commands/frameworks/vue/config.ts @@ -0,0 +1,173 @@ +import { promises as fs } from 'node:fs' +import path from 'pathe' +import ora from 'ora' +import { colors } from 'consola/utils' +import { consola } from 'consola' +import prompts from 'prompts' +import { getRegistryBaseColors, getRegistryStyles } from '../../../utils/registry' +import { + type Config, + DEFAULT_COMPONENTS, + DEFAULT_TAILWIND_CONFIG, + DEFAULT_UTILS, + TAILWIND_CSS_PATH, + getConfig, + rawConfigSchema, + resolveConfigPaths, +} from '../../../utils/get-config' + +export default async function (cwd: string, options: { yes: boolean, cwd: string }, prompt: boolean) { + const existingConfig = await getConfig(cwd) + return prompt ? await promptForConfig(cwd, existingConfig, options.yes) : existingConfig +} + +async function promptForConfig( + cwd: string, + defaultConfig: Config | null = null, + skip = false, +) { + const highlight = (text: string) => colors.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')}? ${colors.gray('(recommended)')}?`, + initial: defaultConfig?.typescript ?? true, + active: 'yes', + inactive: 'no', + }, + { + type: 'select', + name: 'framework', + message: `Which ${highlight('framework')} are you using?`, + choices: [ + { title: 'Vite', value: 'vite' }, + { title: 'Nuxt', value: 'nuxt' }, + { title: 'Laravel', value: 'laravel' }, + { title: 'Astro', value: 'astro' }, + ], + }, + { + 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: 'tsConfigPath', + message: (prev, values) => `Where is your ${highlight(values.typescript ? 'tsconfig.json' : 'jsconfig.json')} file?`, + initial: (prev, values) => { + const prefix = values.framework === 'nuxt' ? '.nuxt/' : './' + const path = values.typescript ? 'tsconfig.json' : 'jsconfig.json' + return prefix + path + }, + }, + { + type: 'text', + name: 'tailwindCss', + message: `Where is your ${highlight('global CSS')} file? ${colors.gray('(this file will be overwritten)')}`, + initial: (prev, values) => defaultConfig?.tailwind.css ?? TAILWIND_CSS_PATH[values.framework as 'vite' | 'nuxt' | 'laravel' | 'astro'], + }, + { + 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: 'tailwindPrefix', + // message: `Are you using a custom ${highlight( + // 'tailwind prefix eg. tw-', + // )}? (Leave blank if not)`, + // initial: '', + // }, + { + type: 'text', + name: 'tailwindConfig', + message: `Where is your ${highlight('tailwind.config')} located? ${colors.gray('(this file will be overwritten)')}`, + initial: (prev, values) => { + if (defaultConfig?.tailwind.config) + return defaultConfig?.tailwind.config + if (values.framework === 'astro') + return 'tailwind.config.mjs' + else return 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://shadcn-vue.com/schema.json', + style: options.style, + typescript: options.typescript, + tsConfigPath: options.tsConfigPath, + framework: options.framework, + tailwind: { + config: options.tailwindConfig, + css: options.tailwindCss, + baseColor: options.tailwindBaseColor, + cssVariables: options.tailwindCssVariables, + // prefix: options.tailwindPrefix, + }, + 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. + consola.log('') + 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) +} diff --git a/packages/cli/src/commands/frameworks/vue/diff.ts b/packages/cli/src/commands/frameworks/vue/diff.ts new file mode 100644 index 00000000..75762bf6 --- /dev/null +++ b/packages/cli/src/commands/frameworks/vue/diff.ts @@ -0,0 +1,155 @@ +import { existsSync, promises as fs } from 'node:fs' +import { consola } from 'consola' +import path from 'pathe' +import { colors } from 'consola/utils' +import { type Change, diffLines } from 'diff' +import type { z } from 'zod' +import type { Config } from '../../../utils/get-config' +import { + fetchTree, + getItemTargetPath, + getRegistryBaseColor, + getRegistryIndex, +} from '@/src/utils/registry' +import type { registryIndexSchema } from '@/src/utils/registry/schema' +import { transform } from '@/src/utils/transformers' + +interface DiffOptions { + yes: boolean + cwd: string + path?: string + component?: string +} + +export default async function (cwd: string, config: Config, options: DiffOptions) { + 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) { + consola.info('No updates found.') + process.exit(0) + } + + consola.info('The following components have updates available:') + for (const component of componentsWithUpdates) { + consola.info(`- ${component.name}`) + for (const change of component.changes) + consola.info(` - ${change.filePath}`) + } + + consola.log('') + consola.info( + `Run ${colors.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) { + consola.error( + `The component ${colors.green(options.component)} does not exist.`, + ) + process.exit(1) + } + + const changes = await diffComponent(component, config) + + if (!changes.length) { + consola.info(`No updates found for ${options.component}.`) + process.exit(0) + } + + for (const change of changes) { + consola.info(`- ${change.filePath}`) + printDiff(change.patch) + consola.log('') + } +} + +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(colors.green(part.value)) + + if (part.removed) + return process.stdout.write(colors.red(part.value)) + + return process.stdout.write(part.value) + } + }) +} diff --git a/packages/cli/src/commands/frameworks/vue/init.ts b/packages/cli/src/commands/frameworks/vue/init.ts new file mode 100644 index 00000000..f3d7a743 --- /dev/null +++ b/packages/cli/src/commands/frameworks/vue/init.ts @@ -0,0 +1,120 @@ +import { existsSync, promises as fs } from 'node:fs' +import { template } from 'lodash-es' +import { addDependency, addDevDependency } from 'nypm' +import { gte } from 'semver' +import { consola } from 'consola' +import path from 'pathe' +import ora from 'ora' +import { applyPrefixesCss } from '../../../utils/transformers/transform-tw-prefix' +import { transformByDetype } from '../../../utils/transformers/transform-sfc' +import { transformCJSToESM } from '../../../utils/transformers/transform-cjs-to-esm' +import * as templates from '../../../utils/templates' +import { getProjectInfo } from '../../../utils/get-project-info' +import { getRegistryBaseColor } from '../../../utils/registry' +import type { Config } from '../../../utils/get-config' + +const PROJECT_DEPENDENCIES = { + base: [ + 'tailwindcss-animate', + 'class-variance-authority', + 'clsx', + 'tailwind-merge', + 'radix-vue', + ], + nuxt: [ + '@nuxtjs/tailwindcss', + ], +} + +export default async function (cwd: string, config: Config) { + const spinner = ora('Initializing project...')?.start() + + // Check in in a Nuxt project. + const { isNuxt, shadcnNuxt } = await getProjectInfo() + if (isNuxt) { + consola.log('') + shadcnNuxt + ? consola.info(`Detected a Nuxt project with 'shadcn-nuxt' v${shadcnNuxt.version}...`) + : consola.warn(`Detected a Nuxt project without 'shadcn-nuxt' module. It's recommended to install it.`) + } + + // 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, + transformCJSToESM( + config.resolvedPaths.tailwindConfig, + config.tailwind.cssVariables + ? template(templates.TAILWIND_CONFIG_WITH_VARIABLES)({ extension, framework: config.framework, prefix: config.tailwind.prefix }) + : template(templates.TAILWIND_CONFIG)({ extension, framework: config.framework, prefix: config.tailwind.prefix }), + ), + 'utf8', + ) + + // Write css file. + const baseColor = await getRegistryBaseColor(config.tailwind.baseColor) + if (baseColor) { + await fs.writeFile( + config.resolvedPaths.tailwindCss, + config.tailwind.cssVariables + ? config.tailwind.prefix + ? applyPrefixesCss(baseColor.cssVarsTemplate, config.tailwind.prefix) + : baseColor.cssVarsTemplate + : baseColor.inlineColorsTemplate, + 'utf8', + ) + } + + // Write cn file. + await fs.writeFile( + `${config.resolvedPaths.utils}.${extension}`, + extension === 'ts' ? templates.UTILS : await transformByDetype(templates.UTILS, '.ts'), + 'utf8', + ) + + spinner?.succeed() + + // Install dependencies. + const dependenciesSpinner = ora('Installing dependencies...')?.start() + + // Starting from `shadcn-nuxt` version 0.10.4, Base dependencies are handled by the module so no need to re-add them by the CLI + const baseDeps = gte(shadcnNuxt?.version || '0.0.0', '0.10.4') ? [] : PROJECT_DEPENDENCIES.base + const iconsDep = config.style === 'new-york' ? ['@radix-icons/vue'] : ['lucide-vue-next'] + const deps = baseDeps.concat(iconsDep).filter(Boolean) + + await Promise.allSettled( + [ + config.framework === 'nuxt' && await addDevDependency(PROJECT_DEPENDENCIES.nuxt, { + cwd, + silent: true, + }), + await addDependency(deps, { + cwd, + silent: true, + }), + ], + ) + + dependenciesSpinner?.succeed() +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 4205be83..53f5a232 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,49 +1,13 @@ -import { existsSync, promises as fs } from 'node:fs' +import { existsSync } from 'node:fs' import process from 'node:process' import path from 'pathe' import { Command } from 'commander' -import { template } from 'lodash-es' -import ora from 'ora' -import prompts from 'prompts' import { z } from 'zod' -import { addDependency, addDevDependency } from 'nypm' import { consola } from 'consola' import { colors } from 'consola/utils' -import { gte } from 'semver' -import { getProjectInfo } from '../utils/get-project-info' -import * as templates from '../utils/templates' -import { - getRegistryBaseColor, - getRegistryBaseColors, - getRegistryStyles, -} from '../utils/registry' import { handleError } from '../utils/handle-error' -import { transformByDetype } from '../utils/transformers/transform-sfc' -import { - type Config, - DEFAULT_COMPONENTS, - DEFAULT_TAILWIND_CONFIG, - DEFAULT_UTILS, - TAILWIND_CSS_PATH, - getConfig, - rawConfigSchema, - resolveConfigPaths, -} from '../utils/get-config' -import { transformCJSToESM } from '../utils/transformers/transform-cjs-to-esm' -import { applyPrefixesCss } from '../utils/transformers/transform-tw-prefix' - -const PROJECT_DEPENDENCIES = { - base: [ - 'tailwindcss-animate', - 'class-variance-authority', - 'clsx', - 'tailwind-merge', - 'radix-vue', - ], - nuxt: [ - '@nuxtjs/tailwindcss', - ], -} +import { getProjectInfo } from '../utils/get-project-info' +import frameworksCommands from './frameworks' const initOptionsSchema = z.object({ cwd: z.string(), @@ -70,11 +34,20 @@ export const init = new Command() process.exit(1) } - // Read config. - const existingConfig = await getConfig(cwd) - const config = await promptForConfig(cwd, existingConfig, options.yes) + // Get the corresponding framework commands + const { isNuxt } = await getProjectInfo() + const framework = isNuxt ? 'nuxt' : 'vue' + const { loadConfig, init } = frameworksCommands[framework] - await runInit(cwd, config) + // Read config + const config = await loadConfig(cwd, options, true) + if (!config) { + consola.error(`Error loading config. Please run the ${colors.green('init')} command again.`) + process.exit(1) + } + + // Init + await init(cwd, config) consola.log('') consola.info( @@ -86,247 +59,3 @@ export const init = new Command() handleError(error) } }) - -export async function promptForConfig( - cwd: string, - defaultConfig: Config | null = null, - skip = false, -) { - const highlight = (text: string) => colors.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')}? ${colors.gray('(recommended)')}?`, - initial: defaultConfig?.typescript ?? true, - active: 'yes', - inactive: 'no', - }, - { - type: 'select', - name: 'framework', - message: `Which ${highlight('framework')} are you using?`, - choices: [ - { title: 'Vite', value: 'vite' }, - { title: 'Nuxt', value: 'nuxt' }, - { title: 'Laravel', value: 'laravel' }, - { title: 'Astro', value: 'astro' }, - ], - }, - { - 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: 'tsConfigPath', - message: (prev, values) => `Where is your ${highlight(values.typescript ? 'tsconfig.json' : 'jsconfig.json')} file?`, - initial: (prev, values) => { - const prefix = values.framework === 'nuxt' ? '.nuxt/' : './' - const path = values.typescript ? 'tsconfig.json' : 'jsconfig.json' - return prefix + path - }, - }, - { - type: 'text', - name: 'tailwindCss', - message: `Where is your ${highlight('global CSS')} file? ${colors.gray('(this file will be overwritten)')}`, - initial: (prev, values) => defaultConfig?.tailwind.css ?? TAILWIND_CSS_PATH[values.framework as 'vite' | 'nuxt' | 'laravel' | 'astro'], - }, - { - 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: 'tailwindPrefix', - // message: `Are you using a custom ${highlight( - // 'tailwind prefix eg. tw-', - // )}? (Leave blank if not)`, - // initial: '', - // }, - { - type: 'text', - name: 'tailwindConfig', - message: `Where is your ${highlight('tailwind.config')} located? ${colors.gray('(this file will be overwritten)')}`, - initial: (prev, values) => { - if (defaultConfig?.tailwind.config) - return defaultConfig?.tailwind.config - if (values.framework === 'astro') - return 'tailwind.config.mjs' - else return 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://shadcn-vue.com/schema.json', - style: options.style, - typescript: options.typescript, - tsConfigPath: options.tsConfigPath, - framework: options.framework, - tailwind: { - config: options.tailwindConfig, - css: options.tailwindCss, - baseColor: options.tailwindBaseColor, - cssVariables: options.tailwindCssVariables, - // prefix: options.tailwindPrefix, - }, - 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. - consola.log('') - 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() - - // Check in in a Nuxt project. - const { isNuxt, shadcnNuxt } = await getProjectInfo() - if (isNuxt) { - consola.log('') - shadcnNuxt - ? consola.info(`Detected a Nuxt project with 'shadcn-nuxt' v${shadcnNuxt.version}...`) - : consola.warn(`Detected a Nuxt project without 'shadcn-nuxt' module. It's recommended to install it.`) - } - - // 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, - transformCJSToESM( - config.resolvedPaths.tailwindConfig, - config.tailwind.cssVariables - ? template(templates.TAILWIND_CONFIG_WITH_VARIABLES)({ extension, framework: config.framework, prefix: config.tailwind.prefix }) - : template(templates.TAILWIND_CONFIG)({ extension, framework: config.framework, prefix: config.tailwind.prefix }), - ), - 'utf8', - ) - - // Write css file. - const baseColor = await getRegistryBaseColor(config.tailwind.baseColor) - if (baseColor) { - await fs.writeFile( - config.resolvedPaths.tailwindCss, - config.tailwind.cssVariables - ? config.tailwind.prefix - ? applyPrefixesCss(baseColor.cssVarsTemplate, config.tailwind.prefix) - : baseColor.cssVarsTemplate - : baseColor.inlineColorsTemplate, - 'utf8', - ) - } - - // Write cn file. - await fs.writeFile( - `${config.resolvedPaths.utils}.${extension}`, - extension === 'ts' ? templates.UTILS : await transformByDetype(templates.UTILS, '.ts'), - 'utf8', - ) - - spinner?.succeed() - - // Install dependencies. - const dependenciesSpinner = ora('Installing dependencies...')?.start() - - // Starting from `shadcn-nuxt` version 0.10.4, Base dependencies are handled by the module so no need to re-add them by the CLI - const baseDeps = gte(shadcnNuxt?.version || '0.0.0', '0.10.4') ? [] : PROJECT_DEPENDENCIES.base - const iconsDep = config.style === 'new-york' ? ['@radix-icons/vue'] : ['lucide-vue-next'] - const deps = baseDeps.concat(iconsDep).filter(Boolean) - - await Promise.allSettled( - [ - config.framework === 'nuxt' && await addDevDependency(PROJECT_DEPENDENCIES.nuxt, { - cwd, - silent: true, - }), - await addDependency(deps, { - cwd, - silent: true, - }), - ], - ) - - dependenciesSpinner?.succeed() -} diff --git a/packages/cli/test/commands/init.test.ts b/packages/cli/test/commands/init.test.ts index 937cd4d0..334cdc62 100644 --- a/packages/cli/test/commands/init.test.ts +++ b/packages/cli/test/commands/init.test.ts @@ -3,7 +3,7 @@ import path from 'pathe' import { addDependency } from 'nypm' import { afterEach, expect, it, vi } from 'vitest' -import { runInit } from '../../src/commands/init' +import runInitVue from '../../src/commands/frameworks/vue/init' import { getConfig } from '../../src/utils/get-config' import * as registry from '../../src/utils/registry' @@ -29,7 +29,7 @@ it('init config-full', async () => { const targetDir = path.resolve(__dirname, '../fixtures/config-full') const config = await getConfig(targetDir) - await runInit(targetDir, config!) + await runInitVue(targetDir, config!) expect(mockMkdir).toHaveBeenNthCalledWith( 1, @@ -98,7 +98,7 @@ it('init config-partial', async () => { const targetDir = path.resolve(__dirname, '../fixtures/config-partial') const config = await getConfig(targetDir) - await runInit(targetDir, config!) + await runInitVue(targetDir, config!) expect(mockMkdir).toHaveBeenNthCalledWith( 1,