feat: add cli

This commit is contained in:
Mukund Shah 2023-08-28 10:50:32 +05:45
parent cabed13294
commit ea5c63a250
No known key found for this signature in database
22 changed files with 1538 additions and 4 deletions

View File

@ -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": {

View File

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

View File

@ -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 <cwd>',
'the working directory. defaults to the current directory.',
process.cwd(),
)
.option('-p, --path <path>', 'the path to add the component to.')
.action(async (components, opts) => {
try {
const options = addOptionsSchema.parse({
components,
...opts,
})
const cwd = path.resolve(options.cwd)
if (!existsSync(cwd)) {
logger.error(`The path ${cwd} does not exist. Please try again.`)
process.exit(1)
}
const config = await getConfig(cwd)
if (!config) {
logger.warn(
`Configuration is missing. Please run ${chalk.green( 'init' )} to create a components.json file.`,
)
process.exit(1)
}
const registryIndex = await getRegistryIndex()
let selectedComponents = options.components
if (!options.components?.length) {
const { components } = await prompts({
type: '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)
}
})

View File

@ -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 <cwd>',
'the working directory. defaults to the current directory.',
process.cwd(),
)
.action(async (name, opts) => {
try {
const options = updateOptionsSchema.parse({
component: name,
...opts,
})
const cwd = path.resolve(options.cwd)
if (!existsSync(cwd)) {
logger.error(`The path ${cwd} does not exist. Please try again.`)
process.exit(1)
}
const config = await getConfig(cwd)
if (!config) {
logger.warn(
`Configuration is missing. Please run ${chalk.green(
'init',
)} to create a components.json file.`,
)
process.exit(1)
}
const registryIndex = await getRegistryIndex()
if (!options.component) {
const targetDir = config.resolvedPaths.components
// Find all components that exist in the project.
const projectComponents = registryIndex.filter((item) => {
for (const file of item.files) {
const filePath = path.resolve(targetDir, file)
if (existsSync(filePath))
return true
}
return false
})
// Check for updates.
const componentsWithUpdates = []
for (const component of projectComponents) {
const changes = await diffComponent(component, config)
if (changes.length) {
componentsWithUpdates.push({
name: component.name,
changes,
})
}
}
if (!componentsWithUpdates.length) {
logger.info('No updates found.')
process.exit(0)
}
logger.info('The following components have updates available:')
for (const component of componentsWithUpdates) {
logger.info(`- ${component.name}`)
for (const change of component.changes)
logger.info(` - ${change.filePath}`)
}
logger.break()
logger.info(
`Run ${chalk.green('diff <component>')} to see the changes.`,
)
process.exit(0)
}
// Show diff for a single component.
const component = registryIndex.find(
item => item.name === options.component,
)
if (!component) {
logger.error(
`The component ${chalk.green(options.component)} does not exist.`,
)
process.exit(1)
}
const changes = await diffComponent(component, config)
if (!changes.length) {
logger.info(`No updates found for ${options.component}.`)
process.exit(0)
}
for (const change of changes) {
logger.info(`- ${change.filePath}`)
printDiff(change.patch)
logger.info('')
}
}
catch (error) {
handleError(error)
}
})
async function diffComponent(
component: z.infer<typeof registryIndexSchema>[number],
config: Config,
) {
const payload = await fetchTree(config.style, [component])
const baseColor = await getRegistryBaseColor(config.tailwind.baseColor)
const changes = []
for (const item of payload) {
const targetDir = await getItemTargetPath(config, item)
if (!targetDir)
continue
for (const file of item.files) {
const filePath = path.resolve(targetDir, file.name)
if (!existsSync(filePath))
continue
const fileContent = await fs.readFile(filePath, 'utf8')
const registryContent = await transform({
filename: file.name,
raw: file.content,
config,
baseColor,
})
const patch = diffLines(registryContent 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)
}
})
}

View File

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

View File

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

View File

@ -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<typeof rawConfigSchema>
export const configSchema = rawConfigSchema
.extend({
resolvedPaths: z.object({
tailwindConfig: z.string(),
tailwindCss: z.string(),
utils: z.string(),
components: z.string(),
}),
})
export type Config = z.infer<typeof configSchema>
export async function 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<RawConfig | null> {
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.`)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<typeof registryIndexSchema>,
names: string[],
) {
const tree: z.infer<typeof registryIndexSchema> = []
for (const name of names) {
const entry = index.find(entry => entry.name === name)
if (!entry)
continue
tree.push(entry)
if (entry.registryDependencies) {
const dependencies = await resolveTree(index, entry.registryDependencies)
tree.push(...dependencies)
}
}
return tree.filter(
(component, index, self) =>
self.findIndex(c => c.name === component.name) === index,
)
}
export async function fetchTree(
style: string,
tree: z.infer<typeof registryIndexSchema>,
) {
try {
const paths = tree.map(item => `styles/${style}/${item.name}.json`)
const result = await fetchRegistry(paths)
return registryWithContentSchema.parse(result)
}
catch (error) {
throw new Error('Failed to fetch tree from registry.')
}
}
export function getItemTargetPath(
config: Config,
item: Pick<z.infer<typeof registryItemWithContentSchema>, 'type'>,
override?: string,
) {
// Allow overrides for all items but ui.
if (override && item.type !== 'components:ui')
return override
const [parent, type] = item.type.split(':')
if (!(parent in config.resolvedPaths))
return null
return path.join(
config.resolvedPaths[parent as keyof typeof config.resolvedPaths],
type,
)
}
async function fetchRegistry(paths: string[]) {
try {
const results = await Promise.all(
paths.map(async (path) => {
const response = await fetch(`${baseUrl}/registry/${path}`, {
agent,
})
return await response.json()
}),
)
return results
}
catch (error) {
// eslint-disable-next-line no-console
console.log(error)
throw new Error(`Failed to fetch registry from ${baseUrl}.`)
}
}

View File

@ -0,0 +1,43 @@
import * as z from 'zod'
// TODO: Extract this to a shared package.
export const registryItemSchema = z.object({
name: z.string(),
dependencies: z.array(z.string()).optional(),
registryDependencies: z.array(z.string()).optional(),
files: z.array(z.string()),
type: z.enum(['components:ui', 'components:component', 'components:example']),
})
export const registryIndexSchema = z.array(registryItemSchema)
export const registryItemWithContentSchema = registryItemSchema.extend({
files: z.array(
z.object({
name: z.string(),
content: z.string(),
}),
),
})
export const registryWithContentSchema = z.array(registryItemWithContentSchema)
export const stylesSchema = z.array(
z.object({
name: z.string(),
label: z.string(),
}),
)
export const registryBaseColorSchema = z.object({
inlineColors: z.object({
light: z.record(z.string(), z.string()),
dark: z.record(z.string(), z.string()),
}),
cssVars: z.object({
light: z.record(z.string(), z.string()),
dark: z.record(z.string(), z.string()),
}),
inlineColorsTemplate: z.string(),
cssVarsTemplate: z.string(),
})

View File

@ -0,0 +1,13 @@
import { type ConfigLoaderSuccessResult, createMatchPath } from 'tsconfig-paths'
export function resolveImport(
importPath: string,
config: Pick<ConfigLoaderSuccessResult, 'absoluteBaseUrl' | 'paths'>,
) {
return createMatchPath(config.absoluteBaseUrl, config.paths)(
importPath,
undefined,
() => true,
['.ts', '.tsx'],
)
}

View File

@ -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")],
}`

View File

@ -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<typeof registryBaseColorSchema>
}
export type Transformer<Output = SourceFile> = (
opts: TransformOpts & {
sourceFile: SourceFile
}
) => Promise<Output>
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,
// })
}

View File

@ -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<typeof registryBaseColorSchema>['inlineColors'],
) {
// Handle border classes.
if (input.includes(' border '))
input = input.replace(' border ', ' border border-border ')
// Build color mappings.
const classNames = input.split(' ')
const lightMode: string[] = []
const darkMode: string[] = []
for (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()}`
}

View File

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

View File

@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.json",
"compilerOptions": {
"isolatedModules": false,
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}

20
tsconfig.json Normal file
View File

@ -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"]
}