This commit is contained in:
Muhammad Mahmoud 2024-10-11 15:18:32 +03:00 committed by GitHub
commit 7503f296b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 695 additions and 605 deletions

View File

@ -1,23 +1,13 @@
import { existsSync, promises as fs, rmSync } from 'node:fs' import { existsSync } from 'node:fs'
import process from 'node:process' import process from 'node:process'
import path from 'pathe' import path from 'pathe'
import { consola } from 'consola' import { consola } from 'consola'
import { colors } from 'consola/utils' import { colors } from 'consola/utils'
import { Command } from 'commander' import { Command } from 'commander'
import ora from 'ora'
import prompts from 'prompts'
import { z } from 'zod' import { z } from 'zod'
import { addDependency, addDevDependency } from 'nypm' import { getProjectInfo } from '../utils/get-project-info'
import { transform } from '@/src/utils/transformers' import frameworksCommands from './frameworks'
import { getConfig } from '@/src/utils/get-config'
import { handleError } from '@/src/utils/handle-error' import { handleError } from '@/src/utils/handle-error'
import {
fetchTree,
getItemTargetPath,
getRegistryBaseColor,
getRegistryIndex,
resolveTree,
} from '@/src/utils/registry'
const addOptionsSchema = z.object({ const addOptionsSchema = z.object({
components: z.array(z.string()).optional(), components: z.array(z.string()).optional(),
@ -55,166 +45,20 @@ export const add = new Command()
process.exit(1) 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) { 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) process.exit(1)
} }
const registryIndex = await getRegistryIndex() // Add components
await add(cwd, config, options)
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.')
} }
catch (error) { catch (error) {
handleError(error) handleError(error)

View File

@ -1,22 +1,13 @@
import { existsSync, promises as fs } from 'node:fs' import { existsSync } from 'node:fs'
import process from 'node:process' import process from 'node:process'
import path from 'pathe' import path from 'pathe'
import { consola } from 'consola' import { consola } from 'consola'
import { colors } from 'consola/utils' import { colors } from 'consola/utils'
import { Command } from 'commander' import { Command } from 'commander'
import { type Change, diffLines } from 'diff'
import { z } from 'zod' import { z } from 'zod'
import type { Config } from '@/src/utils/get-config' import { getProjectInfo } from '../utils/get-project-info'
import { getConfig } from '@/src/utils/get-config' import frameworksCommands from './frameworks'
import { handleError } from '@/src/utils/handle-error' 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({ const updateOptionsSchema = z.object({
component: z.string().optional(), component: z.string().optional(),
@ -49,148 +40,22 @@ export const diff = new Command()
process.exit(1) 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) { if (!config) {
consola.warn( consola.warn(`Configuration is missing. Please run ${colors.green('init')} to create a components.json file.`)
`Configuration is missing. Please run ${colors.green(
'init',
)} to create a components.json file.`,
)
process.exit(1) process.exit(1)
} }
const registryIndex = await getRegistryIndex() // Run Diff Command
await diff(cwd, config, options)
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 <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) {
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('')
}
} }
catch (error) { catch (error) {
handleError(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 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)
}
})
}

View File

@ -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,
},
}

View File

@ -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.')
}

View File

@ -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)
}

View File

@ -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 <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) {
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<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 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)
}
})
}

View File

@ -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()
}

View File

@ -1,49 +1,13 @@
import { existsSync, promises as fs } from 'node:fs' import { existsSync } from 'node:fs'
import process from 'node:process' import process from 'node:process'
import path from 'pathe' import path from 'pathe'
import { Command } from 'commander' import { Command } from 'commander'
import { template } from 'lodash-es'
import ora from 'ora'
import prompts from 'prompts'
import { z } from 'zod' import { z } from 'zod'
import { addDependency, addDevDependency } from 'nypm'
import { consola } from 'consola' import { consola } from 'consola'
import { colors } from 'consola/utils' 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 { handleError } from '../utils/handle-error'
import { transformByDetype } from '../utils/transformers/transform-sfc' import { getProjectInfo } from '../utils/get-project-info'
import { import frameworksCommands from './frameworks'
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',
],
}
const initOptionsSchema = z.object({ const initOptionsSchema = z.object({
cwd: z.string(), cwd: z.string(),
@ -70,11 +34,20 @@ export const init = new Command()
process.exit(1) process.exit(1)
} }
// Read config. // Get the corresponding framework commands
const existingConfig = await getConfig(cwd) const { isNuxt } = await getProjectInfo()
const config = await promptForConfig(cwd, existingConfig, options.yes) 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.log('')
consola.info( consola.info(
@ -86,247 +59,3 @@ export const init = new Command()
handleError(error) 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()
}

View File

@ -3,7 +3,7 @@ import path from 'pathe'
import { addDependency } from 'nypm' import { addDependency } from 'nypm'
import { afterEach, expect, it, vi } from 'vitest' 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 { getConfig } from '../../src/utils/get-config'
import * as registry from '../../src/utils/registry' 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 targetDir = path.resolve(__dirname, '../fixtures/config-full')
const config = await getConfig(targetDir) const config = await getConfig(targetDir)
await runInit(targetDir, config!) await runInitVue(targetDir, config!)
expect(mockMkdir).toHaveBeenNthCalledWith( expect(mockMkdir).toHaveBeenNthCalledWith(
1, 1,
@ -98,7 +98,7 @@ it('init config-partial', async () => {
const targetDir = path.resolve(__dirname, '../fixtures/config-partial') const targetDir = path.resolve(__dirname, '../fixtures/config-partial')
const config = await getConfig(targetDir) const config = await getConfig(targetDir)
await runInit(targetDir, config!) await runInitVue(targetDir, config!)
expect(mockMkdir).toHaveBeenNthCalledWith( expect(mockMkdir).toHaveBeenNthCalledWith(
1, 1,