refactor: new CLI
This commit is contained in:
parent
6927943477
commit
8cd51af246
|
|
@ -56,9 +56,12 @@
|
||||||
"c12": "^2.0.1",
|
"c12": "^2.0.1",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"consola": "^3.2.3",
|
"consola": "^3.2.3",
|
||||||
|
"deepmerge": "^4.3.1",
|
||||||
"diff": "^7.0.0",
|
"diff": "^7.0.0",
|
||||||
|
"fast-glob": "^3.3.2",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
"https-proxy-agent": "^7.0.5",
|
"https-proxy-agent": "^7.0.5",
|
||||||
|
"kleur": "^4.1.5",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"magic-string": "^0.30.13",
|
"magic-string": "^0.30.13",
|
||||||
"nypm": "^0.3.12",
|
"nypm": "^0.3.12",
|
||||||
|
|
@ -66,9 +69,13 @@
|
||||||
"ora": "^8.1.1",
|
"ora": "^8.1.1",
|
||||||
"pathe": "^1.1.2",
|
"pathe": "^1.1.2",
|
||||||
"pkg-types": "^1.2.1",
|
"pkg-types": "^1.2.1",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
"prompts": "^2.4.2",
|
"prompts": "^2.4.2",
|
||||||
"reka-ui": "catalog:",
|
"reka-ui": "catalog:",
|
||||||
"semver": "^7.6.3",
|
"semver": "^7.6.3",
|
||||||
|
"stringify-object": "^5.0.0",
|
||||||
|
"tailwindcss": "^3.4.15",
|
||||||
|
"ts-morph": "^24.0.0",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"vue-metamorph": "3.2.0",
|
"vue-metamorph": "3.2.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|
@ -79,6 +86,7 @@
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
"@types/prompts": "^2.4.9",
|
"@types/prompts": "^2.4.9",
|
||||||
|
"@types/stringify-object": "^4.0.5",
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"type-fest": "^4.27.0",
|
"type-fest": "^4.27.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,35 @@
|
||||||
import { existsSync, promises as fs, rmSync } from 'node:fs'
|
import path from 'node:path'
|
||||||
import process from 'node:process'
|
import { runInit } from '@/src/commands/init'
|
||||||
import { getConfig } from '@/src/utils/get-config'
|
import { preFlightAdd } from '@/src/preflights/preflight-add'
|
||||||
|
import { addComponents } from '@/src/utils/add-components'
|
||||||
|
import * as ERRORS from '@/src/utils/errors'
|
||||||
import { handleError } from '@/src/utils/handle-error'
|
import { handleError } from '@/src/utils/handle-error'
|
||||||
import {
|
import { highlighter } from '@/src/utils/highlighter'
|
||||||
fetchTree,
|
import { logger } from '@/src/utils/logger'
|
||||||
getItemTargetPath,
|
import { getRegistryIndex } from '@/src/utils/registry'
|
||||||
getRegistryBaseColor,
|
|
||||||
getRegistryIndex,
|
|
||||||
resolveTree,
|
|
||||||
} from '@/src/utils/registry'
|
|
||||||
import { transform } from '@/src/utils/transformers'
|
|
||||||
import { Command } from 'commander'
|
import { Command } from 'commander'
|
||||||
import { consola } from 'consola'
|
|
||||||
import { colors } from 'consola/utils'
|
|
||||||
import { addDependency, addDevDependency } from 'nypm'
|
|
||||||
import ora from 'ora'
|
|
||||||
import path from 'pathe'
|
|
||||||
import prompts from 'prompts'
|
import prompts from 'prompts'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const addOptionsSchema = z.object({
|
export const addOptionsSchema = z.object({
|
||||||
components: z.array(z.string()).optional(),
|
components: z.array(z.string()).optional(),
|
||||||
yes: z.boolean(),
|
yes: z.boolean(),
|
||||||
overwrite: z.boolean(),
|
overwrite: z.boolean(),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
all: z.boolean(),
|
all: z.boolean(),
|
||||||
path: z.string().optional(),
|
path: z.string().optional(),
|
||||||
|
silent: z.boolean(),
|
||||||
|
srcDir: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const add = new Command()
|
export const add = new Command()
|
||||||
.name('add')
|
.name('add')
|
||||||
.description('add a component to your project')
|
.description('add a component to your project')
|
||||||
.argument('[components...]', 'the components to add')
|
.argument(
|
||||||
.option('-y, --yes', 'skip confirmation prompt.', true)
|
'[components...]',
|
||||||
|
'the components to add or a url to the component.',
|
||||||
|
)
|
||||||
|
.option('-y, --yes', 'skip confirmation prompt.', false)
|
||||||
.option('-o, --overwrite', 'overwrite existing files.', false)
|
.option('-o, --overwrite', 'overwrite existing files.', false)
|
||||||
.option(
|
.option(
|
||||||
'-c, --cwd <cwd>',
|
'-c, --cwd <cwd>',
|
||||||
|
|
@ -41,182 +38,158 @@ export const add = new Command()
|
||||||
)
|
)
|
||||||
.option('-a, --all', 'add all available components', false)
|
.option('-a, --all', 'add all available components', false)
|
||||||
.option('-p, --path <path>', 'the path to add the component to.')
|
.option('-p, --path <path>', 'the path to add the component to.')
|
||||||
|
.option('-s, --silent', 'mute output.', false)
|
||||||
|
.option(
|
||||||
|
'--src-dir',
|
||||||
|
'use the src directory when creating a new project.',
|
||||||
|
false,
|
||||||
|
)
|
||||||
.action(async (components, opts) => {
|
.action(async (components, opts) => {
|
||||||
try {
|
try {
|
||||||
const options = addOptionsSchema.parse({
|
const options = addOptionsSchema.parse({
|
||||||
components,
|
components,
|
||||||
|
cwd: path.resolve(opts.cwd),
|
||||||
...opts,
|
...opts,
|
||||||
})
|
})
|
||||||
|
|
||||||
const cwd = path.resolve(options.cwd)
|
// Confirm if user is installing themes.
|
||||||
|
// For now, we assume a theme is prefixed with "theme-".
|
||||||
|
const isTheme = options.components?.some(component =>
|
||||||
|
component.includes('theme-'),
|
||||||
|
)
|
||||||
|
if (!options.yes && isTheme) {
|
||||||
|
logger.break()
|
||||||
|
const { confirm } = await prompts({
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'confirm',
|
||||||
|
message: highlighter.warn(
|
||||||
|
'You are about to install a new theme. \nExisting CSS variables will be overwritten. Continue?',
|
||||||
|
),
|
||||||
|
})
|
||||||
|
if (!confirm) {
|
||||||
|
logger.break()
|
||||||
|
logger.log('Theme installation cancelled.')
|
||||||
|
logger.break()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!existsSync(cwd)) {
|
if (!options.components?.length) {
|
||||||
consola.error(`The path ${cwd} does not exist. Please try again.`)
|
options.components = await promptForRegistryComponents(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
let { errors, config } = await preFlightAdd(options)
|
||||||
|
|
||||||
|
// No components.json file. Prompt the user to run init.
|
||||||
|
if (errors[ERRORS.MISSING_CONFIG]) {
|
||||||
|
const { proceed } = await prompts({
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'proceed',
|
||||||
|
message: `You need to create a ${highlighter.info(
|
||||||
|
'components.json',
|
||||||
|
)} file to add components. Proceed?`,
|
||||||
|
initial: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!proceed) {
|
||||||
|
logger.break()
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig(cwd)
|
config = await runInit({
|
||||||
|
cwd: options.cwd,
|
||||||
|
yes: true,
|
||||||
|
force: true,
|
||||||
|
defaults: false,
|
||||||
|
skipPreflight: false,
|
||||||
|
silent: true,
|
||||||
|
isNewProject: false,
|
||||||
|
srcDir: options.srcDir,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// if (errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
|
||||||
|
// const { projectPath } = await createProject({
|
||||||
|
// cwd: options.cwd,
|
||||||
|
// force: options.overwrite,
|
||||||
|
// srcDir: options.srcDir,
|
||||||
|
// })
|
||||||
|
// if (!projectPath) {
|
||||||
|
// logger.break()
|
||||||
|
// process.exit(1)
|
||||||
|
// }
|
||||||
|
// options.cwd = projectPath
|
||||||
|
|
||||||
|
// config = await runInit({
|
||||||
|
// cwd: options.cwd,
|
||||||
|
// yes: true,
|
||||||
|
// force: true,
|
||||||
|
// defaults: false,
|
||||||
|
// skipPreflight: true,
|
||||||
|
// silent: true,
|
||||||
|
// isNewProject: true,
|
||||||
|
// srcDir: options.srcDir,
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
consola.warn(`Configuration is missing. Please run ${colors.green('init')} to create a components.json file.`)
|
throw new Error(
|
||||||
|
`Failed to read config at ${highlighter.info(options.cwd)}.`,
|
||||||
process.exit(1)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const registryIndex = await getRegistryIndex()
|
await addComponents(options.components, config, options)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
logger.break()
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function promptForRegistryComponents(
|
||||||
|
options: z.infer<typeof addOptionsSchema>,
|
||||||
|
) {
|
||||||
|
const registryIndex = await getRegistryIndex()
|
||||||
|
if (!registryIndex) {
|
||||||
|
logger.break()
|
||||||
|
handleError(new Error('Failed to fetch registry index.'))
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.all) {
|
||||||
|
return registryIndex.map(entry => entry.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.components?.length) {
|
||||||
|
return options.components
|
||||||
|
}
|
||||||
|
|
||||||
let selectedComponents = options.all
|
|
||||||
? registryIndex.map(entry => entry.name)
|
|
||||||
: options.components
|
|
||||||
if (!options.components?.length && !options.all) {
|
|
||||||
const { components } = await prompts({
|
const { components } = await prompts({
|
||||||
type: 'multiselect',
|
type: 'multiselect',
|
||||||
name: 'components',
|
name: 'components',
|
||||||
message: 'Which components would you like to add?',
|
message: 'Which components would you like to add?',
|
||||||
hint: 'Space to select. A to toggle all. Enter to submit.',
|
hint: 'Space to select. A to toggle all. Enter to submit.',
|
||||||
instructions: false,
|
instructions: false,
|
||||||
choices: registryIndex.map(entry => ({
|
choices: registryIndex
|
||||||
|
.filter(entry => entry.type === 'registry:ui')
|
||||||
|
.map(entry => ({
|
||||||
title: entry.name,
|
title: entry.name,
|
||||||
value: entry.name,
|
value: entry.name,
|
||||||
selected: options.all
|
selected: options.all ? true : options.components?.includes(entry.name),
|
||||||
? true
|
|
||||||
: options.components?.includes(entry.name),
|
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
selectedComponents = components
|
|
||||||
|
if (!components?.length) {
|
||||||
|
logger.warn('No components selected. Exiting.')
|
||||||
|
logger.info('')
|
||||||
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedComponents?.length) {
|
const result = z.array(z.string()).safeParse(components)
|
||||||
consola.warn('No components selected. Exiting.')
|
if (!result.success) {
|
||||||
process.exit(0)
|
logger.error('')
|
||||||
|
handleError(new Error('Something went wrong. Please try again.'))
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
|
return result.data
|
||||||
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) {
|
|
||||||
handleError(error)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import type { Config } from '@/src/utils/get-config'
|
import type { Config } from '@/src/utils/get-config'
|
||||||
import type { registryIndexSchema } from '@/src/utils/registry/schema'
|
import type { registryIndexSchema } from '@/src/utils/registry/schema'
|
||||||
import { existsSync, promises as fs } from 'node:fs'
|
import { existsSync, promises as fs } from 'node:fs'
|
||||||
import process from 'node:process'
|
import path from 'node:path'
|
||||||
import { getConfig } from '@/src/utils/get-config'
|
import { getConfig } from '@/src/utils/get-config'
|
||||||
import { handleError } from '@/src/utils/handle-error'
|
import { handleError } from '@/src/utils/handle-error'
|
||||||
|
import { highlighter } from '@/src/utils/highlighter'
|
||||||
|
import { logger } from '@/src/utils/logger'
|
||||||
import {
|
import {
|
||||||
fetchTree,
|
fetchTree,
|
||||||
getItemTargetPath,
|
getItemTargetPath,
|
||||||
|
|
@ -12,10 +14,7 @@ import {
|
||||||
} from '@/src/utils/registry'
|
} from '@/src/utils/registry'
|
||||||
import { transform } from '@/src/utils/transformers'
|
import { transform } from '@/src/utils/transformers'
|
||||||
import { Command } from 'commander'
|
import { Command } from 'commander'
|
||||||
import { consola } from 'consola'
|
|
||||||
import { colors } from 'consola/utils'
|
|
||||||
import { type Change, diffLines } from 'diff'
|
import { type Change, diffLines } from 'diff'
|
||||||
import path from 'pathe'
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
const updateOptionsSchema = z.object({
|
const updateOptionsSchema = z.object({
|
||||||
|
|
@ -45,15 +44,15 @@ export const diff = new Command()
|
||||||
const cwd = path.resolve(options.cwd)
|
const cwd = path.resolve(options.cwd)
|
||||||
|
|
||||||
if (!existsSync(cwd)) {
|
if (!existsSync(cwd)) {
|
||||||
consola.error(`The path ${cwd} does not exist. Please try again.`)
|
logger.error(`The path ${cwd} does not exist. Please try again.`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig(cwd)
|
const config = await getConfig(cwd)
|
||||||
if (!config) {
|
if (!config) {
|
||||||
consola.warn(
|
logger.warn(
|
||||||
`Configuration is missing. Please run ${colors.green(
|
`Configuration is missing. Please run ${highlighter.success(
|
||||||
'init',
|
`init`,
|
||||||
)} to create a components.json file.`,
|
)} to create a components.json file.`,
|
||||||
)
|
)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|
@ -61,16 +60,25 @@ export const diff = new Command()
|
||||||
|
|
||||||
const registryIndex = await getRegistryIndex()
|
const registryIndex = await getRegistryIndex()
|
||||||
|
|
||||||
|
if (!registryIndex) {
|
||||||
|
handleError(new Error('Failed to fetch registry index.'))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
if (!options.component) {
|
if (!options.component) {
|
||||||
const targetDir = config.resolvedPaths.components
|
const targetDir = config.resolvedPaths.components
|
||||||
|
|
||||||
// Find all components that exist in the project.
|
// Find all components that exist in the project.
|
||||||
const projectComponents = registryIndex.filter((item) => {
|
const projectComponents = registryIndex.filter((item) => {
|
||||||
for (const file of item.files) {
|
for (const file of item.files ?? []) {
|
||||||
const filePath = path.resolve(targetDir, file)
|
const filePath = path.resolve(
|
||||||
if (existsSync(filePath))
|
targetDir,
|
||||||
|
typeof file === 'string' ? file : file.path,
|
||||||
|
)
|
||||||
|
if (existsSync(filePath)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
@ -88,20 +96,20 @@ export const diff = new Command()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!componentsWithUpdates.length) {
|
if (!componentsWithUpdates.length) {
|
||||||
consola.info('No updates found.')
|
logger.info('No updates found.')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
consola.info('The following components have updates available:')
|
logger.info('The following components have updates available:')
|
||||||
for (const component of componentsWithUpdates) {
|
for (const component of componentsWithUpdates) {
|
||||||
consola.info(`- ${component.name}`)
|
logger.info(`- ${component.name}`)
|
||||||
for (const change of component.changes)
|
for (const change of component.changes) {
|
||||||
consola.info(` - ${change.filePath}`)
|
logger.info(` - ${change.filePath}`)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
consola.log('')
|
logger.break()
|
||||||
consola.info(
|
logger.info(
|
||||||
`Run ${colors.green('diff <component>')} to see the changes.`,
|
`Run ${highlighter.success(`diff <component>`)} to see the changes.`,
|
||||||
)
|
)
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
@ -112,8 +120,10 @@ export const diff = new Command()
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!component) {
|
if (!component) {
|
||||||
consola.error(
|
logger.error(
|
||||||
`The component ${colors.green(options.component)} does not exist.`,
|
`The component ${highlighter.success(
|
||||||
|
options.component,
|
||||||
|
)} does not exist.`,
|
||||||
)
|
)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
@ -121,14 +131,14 @@ export const diff = new Command()
|
||||||
const changes = await diffComponent(component, config)
|
const changes = await diffComponent(component, config)
|
||||||
|
|
||||||
if (!changes.length) {
|
if (!changes.length) {
|
||||||
consola.info(`No updates found for ${options.component}.`)
|
logger.info(`No updates found for ${options.component}.`)
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const change of changes) {
|
for (const change of changes) {
|
||||||
consola.info(`- ${change.filePath}`)
|
logger.info(`- ${change.filePath}`)
|
||||||
printDiff(change.patch)
|
await printDiff(change.patch)
|
||||||
consola.log('')
|
logger.info('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
|
@ -143,24 +153,37 @@ async function diffComponent(
|
||||||
const payload = await fetchTree(config.style, [component])
|
const payload = await fetchTree(config.style, [component])
|
||||||
const baseColor = await getRegistryBaseColor(config.tailwind.baseColor)
|
const baseColor = await getRegistryBaseColor(config.tailwind.baseColor)
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
const changes = []
|
const changes = []
|
||||||
|
|
||||||
for (const item of payload) {
|
for (const item of payload) {
|
||||||
const targetDir = await getItemTargetPath(config, item)
|
const targetDir = await getItemTargetPath(config, item)
|
||||||
|
|
||||||
if (!targetDir)
|
if (!targetDir) {
|
||||||
continue
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
for (const file of item.files) {
|
for (const file of item.files ?? []) {
|
||||||
const filePath = path.resolve(targetDir, file.name)
|
const filePath = path.resolve(
|
||||||
|
targetDir,
|
||||||
|
typeof file === 'string' ? file : file.path,
|
||||||
|
)
|
||||||
|
|
||||||
if (!existsSync(filePath))
|
if (!existsSync(filePath)) {
|
||||||
continue
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const fileContent = await fs.readFile(filePath, 'utf8')
|
const fileContent = await fs.readFile(filePath, 'utf8')
|
||||||
|
|
||||||
|
if (typeof file === 'string' || !file.content) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const registryContent = await transform({
|
const registryContent = await transform({
|
||||||
filename: file.name,
|
filename: file.path,
|
||||||
raw: file.content,
|
raw: file.content,
|
||||||
config,
|
config,
|
||||||
baseColor,
|
baseColor,
|
||||||
|
|
@ -169,7 +192,6 @@ async function diffComponent(
|
||||||
const patch = diffLines(registryContent as string, fileContent)
|
const patch = diffLines(registryContent as string, fileContent)
|
||||||
if (patch.length > 1) {
|
if (patch.length > 1) {
|
||||||
changes.push({
|
changes.push({
|
||||||
file: file.name,
|
|
||||||
filePath,
|
filePath,
|
||||||
patch,
|
patch,
|
||||||
})
|
})
|
||||||
|
|
@ -180,15 +202,15 @@ async function diffComponent(
|
||||||
return changes
|
return changes
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Does is it need to async?
|
async function printDiff(diff: Change[]) {
|
||||||
function printDiff(diff: Change[]) {
|
|
||||||
diff.forEach((part) => {
|
diff.forEach((part) => {
|
||||||
if (part) {
|
if (part) {
|
||||||
if (part.added)
|
if (part.added) {
|
||||||
return process.stdout.write(colors.green(part.value))
|
return process.stdout.write(highlighter.success(part.value))
|
||||||
|
}
|
||||||
if (part.removed)
|
if (part.removed) {
|
||||||
return process.stdout.write(colors.red(part.value))
|
return process.stdout.write(highlighter.error(part.value))
|
||||||
|
}
|
||||||
|
|
||||||
return process.stdout.write(part.value)
|
return process.stdout.write(part.value)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
packages/cli/src/commands/info.ts
Normal file
21
packages/cli/src/commands/info.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { getConfig } from '@/src/utils/get-config'
|
||||||
|
import { getProjectInfo } from '@/src/utils/get-project-info'
|
||||||
|
import { logger } from '@/src/utils/logger'
|
||||||
|
import { Command } from 'commander'
|
||||||
|
import consola from 'consola'
|
||||||
|
|
||||||
|
export const info = new Command()
|
||||||
|
.name('info')
|
||||||
|
.description('get information about your project')
|
||||||
|
.option(
|
||||||
|
'-c, --cwd <cwd>',
|
||||||
|
'the working directory. defaults to the current directory.',
|
||||||
|
process.cwd(),
|
||||||
|
)
|
||||||
|
.action(async (opts) => {
|
||||||
|
logger.info('> project info')
|
||||||
|
consola.log(await getProjectInfo(opts.cwd))
|
||||||
|
logger.break()
|
||||||
|
logger.info('> components.json')
|
||||||
|
consola.log(await getConfig(opts.cwd))
|
||||||
|
})
|
||||||
|
|
@ -1,122 +1,180 @@
|
||||||
import { existsSync, promises as fs } from 'node:fs'
|
import { promises as fs } from 'node:fs'
|
||||||
import process from 'node:process'
|
import path from 'node:path'
|
||||||
import { Command } from 'commander'
|
import { preFlightInit } from '@/src/preflights/preflight-init'
|
||||||
import { consola } from 'consola'
|
import { addComponents } from '@/src/utils/add-components'
|
||||||
import { colors } from 'consola/utils'
|
|
||||||
import { template } from 'lodash-es'
|
|
||||||
import { addDependency } from 'nypm'
|
|
||||||
import ora from 'ora'
|
|
||||||
import path from 'pathe'
|
|
||||||
import prompts from 'prompts'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import {
|
import {
|
||||||
type Config,
|
type Config,
|
||||||
DEFAULT_COMPONENTS,
|
DEFAULT_COMPONENTS,
|
||||||
DEFAULT_TAILWIND_CONFIG,
|
DEFAULT_TAILWIND_CONFIG,
|
||||||
|
DEFAULT_TAILWIND_CSS,
|
||||||
DEFAULT_UTILS,
|
DEFAULT_UTILS,
|
||||||
getConfig,
|
getConfig,
|
||||||
rawConfigSchema,
|
rawConfigSchema,
|
||||||
resolveConfigPaths,
|
resolveConfigPaths,
|
||||||
TAILWIND_CSS_PATH,
|
} from '@/src/utils/get-config'
|
||||||
} from '../utils/get-config'
|
import { getProjectConfig, getProjectInfo } from '@/src/utils/get-project-info'
|
||||||
import { getProjectInfo } from '../utils/get-project-info'
|
import { handleError } from '@/src/utils/handle-error'
|
||||||
import { handleError } from '../utils/handle-error'
|
import { highlighter } from '@/src/utils/highlighter'
|
||||||
import {
|
import { logger } from '@/src/utils/logger'
|
||||||
getRegistryBaseColor,
|
import { getRegistryBaseColors, getRegistryStyles } from '@/src/utils/registry'
|
||||||
getRegistryBaseColors,
|
import { spinner } from '@/src/utils/spinner'
|
||||||
getRegistryStyles,
|
import { updateTailwindContent } from '@/src/utils/updaters/update-tailwind-content'
|
||||||
} from '../utils/registry'
|
import { Command } from 'commander'
|
||||||
import * as templates from '../utils/templates'
|
import prompts from 'prompts'
|
||||||
import { transformCJSToESM } from '../utils/transformers/transform-cjs-to-esm'
|
import { z } from 'zod'
|
||||||
import { transformByDetype } from '../utils/transformers/transform-sfc'
|
|
||||||
import { applyPrefixesCss } from '../utils/transformers/transform-tw-prefix'
|
|
||||||
|
|
||||||
const PROJECT_DEPENDENCIES = {
|
export const initOptionsSchema = z.object({
|
||||||
base: [
|
|
||||||
'tailwindcss-animate',
|
|
||||||
'class-variance-authority',
|
|
||||||
'clsx',
|
|
||||||
'tailwind-merge',
|
|
||||||
'reka-ui',
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const initOptionsSchema = z.object({
|
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
|
components: z.array(z.string()).optional(),
|
||||||
yes: z.boolean(),
|
yes: z.boolean(),
|
||||||
|
defaults: z.boolean(),
|
||||||
|
force: z.boolean(),
|
||||||
|
silent: z.boolean(),
|
||||||
|
isNewProject: z.boolean(),
|
||||||
|
srcDir: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const init = new Command()
|
export const init = new Command()
|
||||||
.name('init')
|
.name('init')
|
||||||
.description('initialize your project and install dependencies')
|
.description('initialize your project and install dependencies')
|
||||||
.option('-y, --yes', 'skip confirmation prompt.', false)
|
.argument(
|
||||||
|
'[components...]',
|
||||||
|
'the components to add or a url to the component.',
|
||||||
|
)
|
||||||
|
.option('-y, --yes', 'skip confirmation prompt.', true)
|
||||||
|
.option('-d, --defaults,', 'use default configuration.', false)
|
||||||
|
.option('-f, --force', 'force overwrite of existing configuration.', false)
|
||||||
.option(
|
.option(
|
||||||
'-c, --cwd <cwd>',
|
'-c, --cwd <cwd>',
|
||||||
'the working directory. defaults to the current directory.',
|
'the working directory. defaults to the current directory.',
|
||||||
process.cwd(),
|
process.cwd(),
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.option('-s, --silent', 'mute output.', false)
|
||||||
try {
|
.option(
|
||||||
const options = initOptionsSchema.parse(opts)
|
'--src-dir',
|
||||||
const cwd = path.resolve(options.cwd)
|
'use the src directory when creating a new project.',
|
||||||
|
false,
|
||||||
// Ensure target directory exists.
|
|
||||||
if (!existsSync(cwd)) {
|
|
||||||
consola.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)
|
|
||||||
|
|
||||||
consola.log('')
|
|
||||||
consola.info(
|
|
||||||
`${colors.green('Success!')} Project initialization completed.`,
|
|
||||||
)
|
)
|
||||||
consola.log('')
|
.action(async (components, opts) => {
|
||||||
|
try {
|
||||||
|
const options = initOptionsSchema.parse({
|
||||||
|
cwd: path.resolve(opts.cwd),
|
||||||
|
isNewProject: false,
|
||||||
|
components,
|
||||||
|
...opts,
|
||||||
|
})
|
||||||
|
|
||||||
|
await runInit(options)
|
||||||
|
|
||||||
|
logger.log(
|
||||||
|
`${highlighter.success(
|
||||||
|
'Success!',
|
||||||
|
)} Project initialization completed.\nYou may now add components.`,
|
||||||
|
)
|
||||||
|
logger.break()
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
logger.break()
|
||||||
handleError(error)
|
handleError(error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function promptForConfig(
|
export async function runInit(
|
||||||
cwd: string,
|
options: z.infer<typeof initOptionsSchema> & {
|
||||||
defaultConfig: Config | null = null,
|
skipPreflight?: boolean
|
||||||
skip = false,
|
},
|
||||||
) {
|
) {
|
||||||
const highlight = (text: string) => colors.cyan(text)
|
let projectInfo
|
||||||
|
if (!options.skipPreflight) {
|
||||||
|
const preflight = await preFlightInit(options)
|
||||||
|
// if (preflight.errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) {
|
||||||
|
// const { projectPath } = await createProject(options)
|
||||||
|
// if (!projectPath) {
|
||||||
|
// process.exit(1)
|
||||||
|
// }
|
||||||
|
// options.cwd = projectPath
|
||||||
|
// options.isNewProject = true
|
||||||
|
// }
|
||||||
|
projectInfo = preflight.projectInfo
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
projectInfo = await getProjectInfo(options.cwd)
|
||||||
|
}
|
||||||
|
|
||||||
const styles = await getRegistryStyles()
|
const projectConfig = await getProjectConfig(options.cwd, projectInfo)
|
||||||
const baseColors = await getRegistryBaseColors()
|
const config = projectConfig
|
||||||
|
? await promptForMinimalConfig(projectConfig, options)
|
||||||
|
: await promptForConfig(await getConfig(options.cwd))
|
||||||
|
|
||||||
|
if (!options.yes) {
|
||||||
|
const { proceed } = await prompts({
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'proceed',
|
||||||
|
message: `Write configuration to ${highlighter.info(
|
||||||
|
'components.json',
|
||||||
|
)}. Proceed?`,
|
||||||
|
initial: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!proceed) {
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write components.json.
|
||||||
|
const componentSpinner = spinner(`Writing components.json.`).start()
|
||||||
|
const targetPath = path.resolve(options.cwd, 'components.json')
|
||||||
|
await fs.writeFile(targetPath, JSON.stringify(config, null, 2), 'utf8')
|
||||||
|
componentSpinner.succeed()
|
||||||
|
|
||||||
|
// Add components.
|
||||||
|
const fullConfig = await resolveConfigPaths(options.cwd, config)
|
||||||
|
const components = ['index', ...(options.components || [])]
|
||||||
|
await addComponents(components, fullConfig, {
|
||||||
|
// Init will always overwrite files.
|
||||||
|
overwrite: true,
|
||||||
|
silent: options.silent,
|
||||||
|
isNewProject:
|
||||||
|
options.isNewProject || projectInfo?.framework.name === 'nuxt',
|
||||||
|
})
|
||||||
|
|
||||||
|
// If a new project is using src dir, let's update the tailwind content config.
|
||||||
|
// TODO: Handle this per framework.
|
||||||
|
if (options.isNewProject && options.srcDir) {
|
||||||
|
await updateTailwindContent(
|
||||||
|
['./src/**/*.{js,ts,jsx,tsx,mdx}'],
|
||||||
|
fullConfig,
|
||||||
|
{
|
||||||
|
silent: options.silent,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fullConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptForConfig(defaultConfig: Config | null = null) {
|
||||||
|
const [styles, baseColors] = await Promise.all([
|
||||||
|
getRegistryStyles(),
|
||||||
|
getRegistryBaseColors(),
|
||||||
|
])
|
||||||
|
|
||||||
|
logger.info('')
|
||||||
const options = await prompts([
|
const options = await prompts([
|
||||||
{
|
{
|
||||||
type: 'toggle',
|
type: 'toggle',
|
||||||
name: 'typescript',
|
name: 'typescript',
|
||||||
message: `Would you like to use ${highlight('TypeScript')}? ${colors.gray('(recommended)')}?`,
|
message: `Would you like to use ${highlighter.info(
|
||||||
|
'TypeScript',
|
||||||
|
)} (recommended)?`,
|
||||||
initial: defaultConfig?.typescript ?? true,
|
initial: defaultConfig?.typescript ?? true,
|
||||||
active: 'yes',
|
active: 'yes',
|
||||||
inactive: 'no',
|
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',
|
type: 'select',
|
||||||
name: 'style',
|
name: 'style',
|
||||||
message: `Which ${highlight('style')} would you like to use?`,
|
message: `Which ${highlighter.info('style')} would you like to use?`,
|
||||||
choices: styles.map(style => ({
|
choices: styles.map(style => ({
|
||||||
title: style.label,
|
title: style.label,
|
||||||
value: style.name,
|
value: style.name,
|
||||||
|
|
@ -125,7 +183,7 @@ export async function promptForConfig(
|
||||||
{
|
{
|
||||||
type: 'select',
|
type: 'select',
|
||||||
name: 'tailwindBaseColor',
|
name: 'tailwindBaseColor',
|
||||||
message: `Which color would you like to use as ${highlight(
|
message: `Which color would you like to use as the ${highlighter.info(
|
||||||
'base color',
|
'base color',
|
||||||
)}?`,
|
)}?`,
|
||||||
choices: baseColors.map(color => ({
|
choices: baseColors.map(color => ({
|
||||||
|
|
@ -133,28 +191,18 @@ export async function promptForConfig(
|
||||||
value: color.name,
|
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',
|
type: 'text',
|
||||||
name: 'tailwindCss',
|
name: 'tailwindCss',
|
||||||
message: `Where is your ${highlight('global CSS')} file? ${colors.gray('(this file will be overwritten)')}`,
|
message: `Where is your ${highlighter.info('global CSS')} file?`,
|
||||||
initial: (prev, values) => defaultConfig?.tailwind.css ?? TAILWIND_CSS_PATH[values.framework as 'vite' | 'nuxt' | 'laravel' | 'astro'],
|
initial: defaultConfig?.tailwind.css ?? DEFAULT_TAILWIND_CSS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'toggle',
|
type: 'toggle',
|
||||||
name: 'tailwindCssVariables',
|
name: 'tailwindCssVariables',
|
||||||
message: `Would you like to use ${highlight(
|
message: `Would you like to use ${highlighter.info(
|
||||||
'CSS variables',
|
'CSS variables',
|
||||||
)} for colors?`,
|
)} for theming?`,
|
||||||
initial: defaultConfig?.tailwind.cssVariables ?? true,
|
initial: defaultConfig?.tailwind.cssVariables ?? true,
|
||||||
active: 'yes',
|
active: 'yes',
|
||||||
inactive: 'no',
|
inactive: 'no',
|
||||||
|
|
@ -162,7 +210,7 @@ export async function promptForConfig(
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'tailwindPrefix',
|
name: 'tailwindPrefix',
|
||||||
message: `Are you using a custom ${highlight(
|
message: `Are you using a custom ${highlighter.info(
|
||||||
'tailwind prefix eg. tw-',
|
'tailwind prefix eg. tw-',
|
||||||
)}? (Leave blank if not)`,
|
)}? (Leave blank if not)`,
|
||||||
initial: '',
|
initial: '',
|
||||||
|
|
@ -170,35 +218,30 @@ export async function promptForConfig(
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'tailwindConfig',
|
name: 'tailwindConfig',
|
||||||
message: `Where is your ${highlight('tailwind.config')} located? ${colors.gray('(this file will be overwritten)')}`,
|
message: `Where is your ${highlighter.info(
|
||||||
initial: (prev, values) => {
|
'tailwind.config.js',
|
||||||
if (defaultConfig?.tailwind.config)
|
)} located?`,
|
||||||
return defaultConfig?.tailwind.config
|
initial: defaultConfig?.tailwind.config ?? DEFAULT_TAILWIND_CONFIG,
|
||||||
if (values.framework === 'astro')
|
|
||||||
return 'tailwind.config.mjs'
|
|
||||||
else return DEFAULT_TAILWIND_CONFIG
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'components',
|
name: 'components',
|
||||||
message: `Configure the import alias for ${highlight('components')}:`,
|
message: `Configure the import alias for ${highlighter.info(
|
||||||
|
'components',
|
||||||
|
)}:`,
|
||||||
initial: defaultConfig?.aliases.components ?? DEFAULT_COMPONENTS,
|
initial: defaultConfig?.aliases.components ?? DEFAULT_COMPONENTS,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'utils',
|
name: 'utils',
|
||||||
message: `Configure the import alias for ${highlight('utils')}:`,
|
message: `Configure the import alias for ${highlighter.info('utils')}:`,
|
||||||
initial: defaultConfig?.aliases.utils ?? DEFAULT_UTILS,
|
initial: defaultConfig?.aliases.utils ?? DEFAULT_UTILS,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const config = rawConfigSchema.parse({
|
return rawConfigSchema.parse({
|
||||||
$schema: 'https://shadcn-vue.com/schema.json',
|
$schema: 'https://ui.shadcn.com/schema.json',
|
||||||
style: options.style,
|
style: options.style,
|
||||||
typescript: options.typescript,
|
|
||||||
tsConfigPath: options.tsConfigPath,
|
|
||||||
framework: options.framework,
|
|
||||||
tailwind: {
|
tailwind: {
|
||||||
config: options.tailwindConfig,
|
config: options.tailwindConfig,
|
||||||
css: options.tailwindCss,
|
css: options.tailwindCss,
|
||||||
|
|
@ -206,113 +249,79 @@ export async function promptForConfig(
|
||||||
cssVariables: options.tailwindCssVariables,
|
cssVariables: options.tailwindCssVariables,
|
||||||
prefix: options.tailwindPrefix,
|
prefix: options.tailwindPrefix,
|
||||||
},
|
},
|
||||||
|
typescript: options.typescript,
|
||||||
aliases: {
|
aliases: {
|
||||||
utils: options.utils,
|
utils: options.utils,
|
||||||
components: options.components,
|
components: options.components,
|
||||||
|
// TODO: fix this.
|
||||||
|
lib: options.components.replace(/\/components$/, 'lib'),
|
||||||
|
hooks: options.components.replace(/\/components$/, 'hooks'),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!skip) {
|
async function promptForMinimalConfig(
|
||||||
const { proceed } = await prompts({
|
defaultConfig: Config,
|
||||||
type: 'confirm',
|
opts: z.infer<typeof initOptionsSchema>,
|
||||||
name: 'proceed',
|
) {
|
||||||
message: `Write configuration to ${highlight('components.json')}. Proceed?`,
|
let style = defaultConfig.style
|
||||||
initial: true,
|
let baseColor = defaultConfig.tailwind.baseColor
|
||||||
|
let cssVariables = defaultConfig.tailwind.cssVariables
|
||||||
|
|
||||||
|
if (!opts.defaults) {
|
||||||
|
const [styles, baseColors] = await Promise.all([
|
||||||
|
getRegistryStyles(),
|
||||||
|
getRegistryBaseColors(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const options = await prompts([
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
name: 'style',
|
||||||
|
message: `Which ${highlighter.info('style')} would you like to use?`,
|
||||||
|
choices: styles.map(style => ({
|
||||||
|
title: style.label,
|
||||||
|
value: style.name,
|
||||||
|
})),
|
||||||
|
initial: styles.findIndex(s => s.name === style),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
name: 'tailwindBaseColor',
|
||||||
|
message: `Which color would you like to use as the ${highlighter.info(
|
||||||
|
'base color',
|
||||||
|
)}?`,
|
||||||
|
choices: baseColors.map(color => ({
|
||||||
|
title: color.label,
|
||||||
|
value: color.name,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'toggle',
|
||||||
|
name: 'tailwindCssVariables',
|
||||||
|
message: `Would you like to use ${highlighter.info(
|
||||||
|
'CSS variables',
|
||||||
|
)} for theming?`,
|
||||||
|
initial: defaultConfig?.tailwind.cssVariables,
|
||||||
|
active: 'yes',
|
||||||
|
inactive: 'no',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
style = options.style
|
||||||
|
baseColor = options.tailwindBaseColor
|
||||||
|
cssVariables = options.tailwindCssVariables
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawConfigSchema.parse({
|
||||||
|
$schema: defaultConfig?.$schema,
|
||||||
|
style,
|
||||||
|
tailwind: {
|
||||||
|
...defaultConfig?.tailwind,
|
||||||
|
baseColor,
|
||||||
|
cssVariables,
|
||||||
|
},
|
||||||
|
aliases: defaultConfig?.aliases,
|
||||||
|
iconLibrary: defaultConfig?.iconLibrary,
|
||||||
})
|
})
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
const iconsDep = config.style === 'new-york' ? ['@radix-icons/vue'] : ['lucide-vue-next']
|
|
||||||
const deps = PROJECT_DEPENDENCIES.base.concat(iconsDep).filter(Boolean)
|
|
||||||
|
|
||||||
await addDependency(deps, {
|
|
||||||
cwd,
|
|
||||||
silent: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
dependenciesSpinner?.succeed()
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
89
packages/cli/src/commands/migrate.ts
Normal file
89
packages/cli/src/commands/migrate.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import path from 'node:path'
|
||||||
|
import { migrateIcons } from '@/src/migrations/migrate-icons'
|
||||||
|
import { preFlightMigrate } from '@/src/preflights/preflight-migrate'
|
||||||
|
import * as ERRORS from '@/src/utils/errors'
|
||||||
|
import { handleError } from '@/src/utils/handle-error'
|
||||||
|
import { Command } from 'commander'
|
||||||
|
import consola from 'consola'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const migrations = [
|
||||||
|
{
|
||||||
|
name: 'icons',
|
||||||
|
description: 'migrate your ui components to a different icon library.',
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const migrateOptionsSchema = z.object({
|
||||||
|
cwd: z.string(),
|
||||||
|
list: z.boolean(),
|
||||||
|
migration: z
|
||||||
|
.string()
|
||||||
|
.refine(
|
||||||
|
value =>
|
||||||
|
value && migrations.some(migration => migration.name === value),
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
'You must specify a valid migration. Run `shadcn migrate --list` to see available migrations.',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const migrate = new Command()
|
||||||
|
.name('migrate')
|
||||||
|
.description('run a migration.')
|
||||||
|
.argument('[migration]', 'the migration to run.')
|
||||||
|
.option(
|
||||||
|
'-c, --cwd <cwd>',
|
||||||
|
'the working directory. defaults to the current directory.',
|
||||||
|
process.cwd(),
|
||||||
|
)
|
||||||
|
.option('-l, --list', 'list all migrations.', false)
|
||||||
|
.action(async (migration, opts) => {
|
||||||
|
try {
|
||||||
|
const options = migrateOptionsSchema.parse({
|
||||||
|
cwd: path.resolve(opts.cwd),
|
||||||
|
migration,
|
||||||
|
list: opts.list,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (options.list || !options.migration) {
|
||||||
|
consola.info('Available migrations:')
|
||||||
|
for (const migration of migrations) {
|
||||||
|
consola.info(`- ${migration.name}: ${migration.description}`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.migration) {
|
||||||
|
throw new Error(
|
||||||
|
'You must specify a migration. Run `shadcn migrate --list` to see available migrations.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { errors, config } = await preFlightMigrate(options)
|
||||||
|
|
||||||
|
if (
|
||||||
|
errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]
|
||||||
|
|| errors[ERRORS.MISSING_CONFIG]
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'No `components.json` file found. Ensure you are at the root of your project.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw new Error(
|
||||||
|
'Something went wrong reading your `components.json` file. Please ensure you have a valid `components.json` file.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.migration === 'icons') {
|
||||||
|
await migrateIcons(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -1,29 +1,32 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import process from 'node:process'
|
|
||||||
|
|
||||||
import { add } from '@/src/commands/add'
|
import { add } from '@/src/commands/add'
|
||||||
|
|
||||||
import { diff } from '@/src/commands/diff'
|
import { diff } from '@/src/commands/diff'
|
||||||
|
import { info } from '@/src/commands/info'
|
||||||
import { init } from '@/src/commands/init'
|
import { init } from '@/src/commands/init'
|
||||||
import { getPackageInfo } from '@/src/utils/get-package-info'
|
import { migrate } from '@/src/commands/migrate'
|
||||||
import { Command } from 'commander'
|
import { Command } from 'commander'
|
||||||
|
|
||||||
|
import packageJson from '../package.json'
|
||||||
|
|
||||||
process.on('SIGINT', () => process.exit(0))
|
process.on('SIGINT', () => process.exit(0))
|
||||||
process.on('SIGTERM', () => process.exit(0))
|
process.on('SIGTERM', () => process.exit(0))
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const packageInfo = await getPackageInfo()
|
|
||||||
|
|
||||||
const program = new Command()
|
const program = new Command()
|
||||||
.name('shadcn-vue')
|
.name('shadcn-vue')
|
||||||
.description('add components and dependencies to your project')
|
.description('add components and dependencies to your project')
|
||||||
.version(
|
.version(
|
||||||
packageInfo.version || '1.0.0',
|
packageJson.version || '1.0.0',
|
||||||
'-v, --version',
|
'-v, --version',
|
||||||
'display the version number',
|
'display the version number',
|
||||||
)
|
)
|
||||||
|
|
||||||
program.addCommand(init).addCommand(add).addCommand(diff)
|
program
|
||||||
|
.addCommand(init)
|
||||||
|
.addCommand(add)
|
||||||
|
.addCommand(diff)
|
||||||
|
.addCommand(migrate)
|
||||||
|
.addCommand(info)
|
||||||
|
|
||||||
program.parse()
|
program.parse()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
158
packages/cli/src/migrations/migrate-icons.test.ts
Normal file
158
packages/cli/src/migrations/migrate-icons.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { migrateIconsFile } from './migrate-icons'
|
||||||
|
|
||||||
|
describe('migrateIconsFile', () => {
|
||||||
|
it('should replace radix icons with lucide icons', async () => {
|
||||||
|
const input = `
|
||||||
|
import { CheckIcon, CloseIcon } from "@radix-ui/react-icons"
|
||||||
|
import { Something } from "other-package"
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CheckIcon className="w-4 h-4" />
|
||||||
|
<CloseIcon />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}`
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await migrateIconsFile(input, 'radix', 'lucide', {
|
||||||
|
Check: {
|
||||||
|
lucide: 'Check',
|
||||||
|
radix: 'CheckIcon',
|
||||||
|
},
|
||||||
|
X: {
|
||||||
|
lucide: 'X',
|
||||||
|
radix: 'CloseIcon',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
"import { Something } from "other-package"
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
<X />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null if no radix icons are found', async () => {
|
||||||
|
const input = `
|
||||||
|
import { Something } from "other-package"
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
return <div>No icons here</div>
|
||||||
|
}`
|
||||||
|
|
||||||
|
expect(await migrateIconsFile(input, 'lucide', 'radix', {}))
|
||||||
|
.toMatchInlineSnapshot(`
|
||||||
|
"import { Something } from "other-package"
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
return <div>No icons here</div>
|
||||||
|
}"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle mixed icon imports from different packages', async () => {
|
||||||
|
const input = `
|
||||||
|
import { CheckIcon } from "@radix-ui/react-icons"
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { Something } from "other-package"
|
||||||
|
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CheckIcon className="w-4 h-4" />
|
||||||
|
<AlertCircle />
|
||||||
|
<Cross2Icon />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}`
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await migrateIconsFile(input, 'radix', 'lucide', {
|
||||||
|
Check: {
|
||||||
|
lucide: 'Check',
|
||||||
|
radix: 'CheckIcon',
|
||||||
|
},
|
||||||
|
X: {
|
||||||
|
lucide: 'X',
|
||||||
|
radix: 'Cross2Icon',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
"import { AlertCircle } from "lucide-react"
|
||||||
|
import { Something } from "other-package"
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
<AlertCircle />
|
||||||
|
<X />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preserve all props and children on icons', async () => {
|
||||||
|
const input = `
|
||||||
|
import { CheckIcon, Cross2Icon } from "@radix-ui/react-icons"
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CheckIcon
|
||||||
|
className="w-4 h-4"
|
||||||
|
onClick={handleClick}
|
||||||
|
data-testid="check-icon"
|
||||||
|
>
|
||||||
|
<span>Child content</span>
|
||||||
|
</CheckIcon>
|
||||||
|
<Cross2Icon style={{ color: 'red' }} aria-label="Close" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}`
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await migrateIconsFile(input, 'radix', 'lucide', {
|
||||||
|
Check: {
|
||||||
|
lucide: 'Check',
|
||||||
|
radix: 'CheckIcon',
|
||||||
|
},
|
||||||
|
X: {
|
||||||
|
lucide: 'X',
|
||||||
|
radix: 'Cross2Icon',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toMatchInlineSnapshot(`
|
||||||
|
"import { Check, X } from "lucide-react";
|
||||||
|
|
||||||
|
export function Component() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CheckIcon
|
||||||
|
className="w-4 h-4"
|
||||||
|
onClick={handleClick}
|
||||||
|
data-testid="check-icon"
|
||||||
|
>
|
||||||
|
<span>Child content</span>
|
||||||
|
</CheckIcon>
|
||||||
|
<X style={{ color: 'red' }} aria-label="Close" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}"
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
})
|
||||||
201
packages/cli/src/migrations/migrate-icons.ts
Normal file
201
packages/cli/src/migrations/migrate-icons.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import type { Config } from '@/src/utils/get-config'
|
||||||
|
import type { iconsSchema } from '@/src/utils/registry/schema'
|
||||||
|
import type { z } from 'zod'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { highlighter } from '@/src/utils/highlighter'
|
||||||
|
import { ICON_LIBRARIES } from '@/src/utils/icon-libraries'
|
||||||
|
import { logger } from '@/src/utils/logger'
|
||||||
|
import { getRegistryIcons } from '@/src/utils/registry'
|
||||||
|
import { spinner } from '@/src/utils/spinner'
|
||||||
|
import { updateDependencies } from '@/src/utils/updaters/update-dependencies'
|
||||||
|
import fg from 'fast-glob'
|
||||||
|
import prompts from 'prompts'
|
||||||
|
import { Project, ScriptKind, SyntaxKind } from 'ts-morph'
|
||||||
|
|
||||||
|
export async function migrateIcons(config: Config) {
|
||||||
|
if (!config.resolvedPaths.ui) {
|
||||||
|
throw new Error(
|
||||||
|
'We could not find a valid `ui` path in your `components.json` file. Please ensure you have a valid `ui` path in your `components.json` file.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiPath = config.resolvedPaths.ui
|
||||||
|
const [files, registryIcons] = await Promise.all([
|
||||||
|
fg('**/*.{js,ts,jsx,tsx}', {
|
||||||
|
cwd: uiPath,
|
||||||
|
}),
|
||||||
|
getRegistryIcons(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (Object.keys(registryIcons).length === 0) {
|
||||||
|
throw new Error('Something went wrong fetching the registry icons.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryChoices = Object.entries(ICON_LIBRARIES).map(
|
||||||
|
([name, iconLibrary]) => ({
|
||||||
|
title: iconLibrary.name,
|
||||||
|
value: name,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const migrateOptions = await prompts([
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
name: 'sourceLibrary',
|
||||||
|
message: `Which icon library would you like to ${highlighter.info(
|
||||||
|
'migrate from',
|
||||||
|
)}?`,
|
||||||
|
choices: libraryChoices,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
name: 'targetLibrary',
|
||||||
|
message: `Which icon library would you like to ${highlighter.info(
|
||||||
|
'migrate to',
|
||||||
|
)}?`,
|
||||||
|
choices: libraryChoices,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
if (migrateOptions.sourceLibrary === migrateOptions.targetLibrary) {
|
||||||
|
throw new Error(
|
||||||
|
'You cannot migrate to the same icon library. Please choose a different icon library.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
migrateOptions.sourceLibrary in ICON_LIBRARIES
|
||||||
|
&& migrateOptions.targetLibrary in ICON_LIBRARIES
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid icon library. Please choose a valid icon library.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceLibrary
|
||||||
|
= ICON_LIBRARIES[migrateOptions.sourceLibrary as keyof typeof ICON_LIBRARIES]
|
||||||
|
const targetLibrary
|
||||||
|
= ICON_LIBRARIES[migrateOptions.targetLibrary as keyof typeof ICON_LIBRARIES]
|
||||||
|
const { confirm } = await prompts({
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'confirm',
|
||||||
|
initial: true,
|
||||||
|
message: `We will migrate ${highlighter.info(
|
||||||
|
files.length,
|
||||||
|
)} files in ${highlighter.info(
|
||||||
|
`./${path.relative(config.resolvedPaths.cwd, uiPath)}`,
|
||||||
|
)} from ${highlighter.info(sourceLibrary.name)} to ${highlighter.info(
|
||||||
|
targetLibrary.name,
|
||||||
|
)}. Continue?`,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirm) {
|
||||||
|
logger.info('Migration cancelled.')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetLibrary.package) {
|
||||||
|
await updateDependencies([targetLibrary.package], config, {
|
||||||
|
silent: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrationSpinner = spinner(`Migrating icons...`)?.start()
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
files.map(async (file) => {
|
||||||
|
migrationSpinner.text = `Migrating ${file}...`
|
||||||
|
|
||||||
|
const filePath = path.join(uiPath, file)
|
||||||
|
const fileContent = await fs.readFile(filePath, 'utf-8')
|
||||||
|
|
||||||
|
const content = await migrateIconsFile(
|
||||||
|
fileContent,
|
||||||
|
migrateOptions.sourceLibrary,
|
||||||
|
migrateOptions.targetLibrary,
|
||||||
|
registryIcons,
|
||||||
|
)
|
||||||
|
|
||||||
|
await fs.writeFile(filePath, content)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
migrationSpinner.succeed('Migration complete.')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function migrateIconsFile(
|
||||||
|
content: string,
|
||||||
|
sourceLibrary: keyof typeof ICON_LIBRARIES,
|
||||||
|
targetLibrary: keyof typeof ICON_LIBRARIES,
|
||||||
|
iconsMapping: z.infer<typeof iconsSchema>,
|
||||||
|
) {
|
||||||
|
const sourceLibraryImport = ICON_LIBRARIES[sourceLibrary]?.import
|
||||||
|
const targetLibraryImport = ICON_LIBRARIES[targetLibrary]?.import
|
||||||
|
|
||||||
|
const dir = await fs.mkdtemp(path.join(tmpdir(), 'shadcn-'))
|
||||||
|
const project = new Project({
|
||||||
|
compilerOptions: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const tempFile = path.join(
|
||||||
|
dir,
|
||||||
|
`shadcn-icons-${randomBytes(4).toString('hex')}.tsx`,
|
||||||
|
)
|
||||||
|
const sourceFile = project.createSourceFile(tempFile, content, {
|
||||||
|
scriptKind: ScriptKind.TSX,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find all sourceLibrary imports.
|
||||||
|
const targetedIcons: string[] = []
|
||||||
|
for (const importDeclaration of sourceFile.getImportDeclarations() ?? []) {
|
||||||
|
if (
|
||||||
|
importDeclaration.getModuleSpecifier()?.getText()
|
||||||
|
!== `"${sourceLibraryImport}"`
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const specifier of importDeclaration.getNamedImports() ?? []) {
|
||||||
|
const iconName = specifier.getName()
|
||||||
|
|
||||||
|
// TODO: this is O(n^2) but okay for now.
|
||||||
|
const targetedIcon = Object.values(iconsMapping).find(
|
||||||
|
icon => icon[sourceLibrary] === iconName,
|
||||||
|
)?.[targetLibrary]
|
||||||
|
|
||||||
|
if (!targetedIcon || targetedIcons.includes(targetedIcon)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
targetedIcons.push(targetedIcon)
|
||||||
|
|
||||||
|
// Remove the named import.
|
||||||
|
specifier.remove()
|
||||||
|
|
||||||
|
// Replace with the targeted icon.
|
||||||
|
sourceFile
|
||||||
|
.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement)
|
||||||
|
.filter(node => node.getTagNameNode()?.getText() === iconName)
|
||||||
|
.forEach(node => node.getTagNameNode()?.replaceWithText(targetedIcon))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the named import is empty, remove the import declaration.
|
||||||
|
if (importDeclaration.getNamedImports()?.length === 0) {
|
||||||
|
importDeclaration.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetedIcons.length > 0) {
|
||||||
|
sourceFile.addImportDeclaration({
|
||||||
|
moduleSpecifier: targetLibraryImport,
|
||||||
|
namedImports: targetedIcons.map(icon => ({
|
||||||
|
name: icon,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return await sourceFile.getText()
|
||||||
|
}
|
||||||
62
packages/cli/src/preflights/preflight-add.ts
Normal file
62
packages/cli/src/preflights/preflight-add.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import type { addOptionsSchema } from '@/src/commands/add'
|
||||||
|
import type { z } from 'zod'
|
||||||
|
import path from 'node:path'
|
||||||
|
import * as ERRORS from '@/src/utils/errors'
|
||||||
|
import { getConfig } from '@/src/utils/get-config'
|
||||||
|
import { highlighter } from '@/src/utils/highlighter'
|
||||||
|
import { logger } from '@/src/utils/logger'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
|
||||||
|
export async function preFlightAdd(options: z.infer<typeof addOptionsSchema>) {
|
||||||
|
const errors: Record<string, boolean> = {}
|
||||||
|
|
||||||
|
// Ensure target directory exists.
|
||||||
|
// Check for empty project. We assume if no package.json exists, the project is empty.
|
||||||
|
if (
|
||||||
|
!fs.existsSync(options.cwd)
|
||||||
|
|| !fs.existsSync(path.resolve(options.cwd, 'package.json'))
|
||||||
|
) {
|
||||||
|
errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT] = true
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
config: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing components.json file.
|
||||||
|
if (!fs.existsSync(path.resolve(options.cwd, 'components.json'))) {
|
||||||
|
errors[ERRORS.MISSING_CONFIG] = true
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
config: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getConfig(options.cwd)
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
config: config!,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
logger.break()
|
||||||
|
logger.error(
|
||||||
|
`An invalid ${highlighter.info(
|
||||||
|
'components.json',
|
||||||
|
)} file was found at ${highlighter.info(
|
||||||
|
options.cwd,
|
||||||
|
)}.\nBefore you can add components, you must create a valid ${highlighter.info(
|
||||||
|
'components.json',
|
||||||
|
)} file by running the ${highlighter.info('init')} command.`,
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
`Learn more at ${highlighter.info(
|
||||||
|
'https://ui.shadcn.com/docs/components-json',
|
||||||
|
)}.`,
|
||||||
|
)
|
||||||
|
logger.break()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
packages/cli/src/preflights/preflight-init.ts
Normal file
146
packages/cli/src/preflights/preflight-init.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import type { initOptionsSchema } from '@/src/commands/init'
|
||||||
|
import type { z } from 'zod'
|
||||||
|
import path from 'node:path'
|
||||||
|
import * as ERRORS from '@/src/utils/errors'
|
||||||
|
import { getProjectInfo, type ProjectInfo } from '@/src/utils/get-project-info'
|
||||||
|
import { highlighter } from '@/src/utils/highlighter'
|
||||||
|
import { logger } from '@/src/utils/logger'
|
||||||
|
import { spinner } from '@/src/utils/spinner'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
|
||||||
|
export async function preFlightInit(options: z.infer<typeof initOptionsSchema>) {
|
||||||
|
const errors: Record<string, boolean> = {}
|
||||||
|
|
||||||
|
// Ensure target directory exists.
|
||||||
|
// Check for empty project. We assume if no package.json exists, the project is empty.
|
||||||
|
if (
|
||||||
|
!fs.existsSync(options.cwd)
|
||||||
|
|| !fs.existsSync(path.resolve(options.cwd, 'package.json'))
|
||||||
|
) {
|
||||||
|
errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT] = true
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
projectInfo: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectSpinner = spinner(`Preflight checks.`, {
|
||||||
|
silent: options.silent,
|
||||||
|
}).start()
|
||||||
|
|
||||||
|
if (
|
||||||
|
fs.existsSync(path.resolve(options.cwd, 'components.json'))
|
||||||
|
&& !options.force
|
||||||
|
) {
|
||||||
|
projectSpinner?.fail()
|
||||||
|
logger.break()
|
||||||
|
logger.error(
|
||||||
|
`A ${highlighter.info(
|
||||||
|
'components.json',
|
||||||
|
)} file already exists at ${highlighter.info(
|
||||||
|
options.cwd,
|
||||||
|
)}.\nTo start over, remove the ${highlighter.info(
|
||||||
|
'components.json',
|
||||||
|
)} file and run ${highlighter.info('init')} again.`,
|
||||||
|
)
|
||||||
|
logger.break()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
projectSpinner?.succeed()
|
||||||
|
|
||||||
|
const frameworkSpinner = spinner(`Verifying framework.`, {
|
||||||
|
silent: options.silent,
|
||||||
|
}).start()
|
||||||
|
const projectInfo = await getProjectInfo(options.cwd) as ProjectInfo
|
||||||
|
// if (!projectInfo || projectInfo?.framework.name === 'manual') {
|
||||||
|
// errors[ERRORS.UNSUPPORTED_FRAMEWORK] = true
|
||||||
|
// frameworkSpinner?.fail()
|
||||||
|
// logger.break()
|
||||||
|
// if (projectInfo?.framework.links.installation) {
|
||||||
|
// logger.error(
|
||||||
|
// `We could not detect a supported framework at ${highlighter.info(
|
||||||
|
// options.cwd,
|
||||||
|
// )}.\n`
|
||||||
|
// + `Visit ${highlighter.info(
|
||||||
|
// projectInfo?.framework.links.installation,
|
||||||
|
// )} to manually configure your project.\nOnce configured, you can use the cli to add components.`,
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// logger.break()
|
||||||
|
// process.exit(1)
|
||||||
|
// }
|
||||||
|
if (!projectInfo) {
|
||||||
|
logger.break()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
frameworkSpinner?.succeed(
|
||||||
|
`Verifying framework. Found ${highlighter.info(
|
||||||
|
projectInfo.framework.label,
|
||||||
|
)}.`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const tailwindSpinner = spinner(`Validating Tailwind CSS.`, {
|
||||||
|
silent: options.silent,
|
||||||
|
}).start()
|
||||||
|
if (!projectInfo?.tailwindConfigFile || !projectInfo?.tailwindCssFile) {
|
||||||
|
errors[ERRORS.TAILWIND_NOT_CONFIGURED] = true
|
||||||
|
tailwindSpinner?.fail()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
tailwindSpinner?.succeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
const tsConfigSpinner = spinner(`Validating import alias.`, {
|
||||||
|
silent: options.silent,
|
||||||
|
}).start()
|
||||||
|
if (!projectInfo?.aliasPrefix) {
|
||||||
|
errors[ERRORS.IMPORT_ALIAS_MISSING] = true
|
||||||
|
tsConfigSpinner?.fail()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
tsConfigSpinner?.succeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
if (errors[ERRORS.TAILWIND_NOT_CONFIGURED]) {
|
||||||
|
logger.break()
|
||||||
|
logger.error(
|
||||||
|
`No Tailwind CSS configuration found at ${highlighter.info(
|
||||||
|
options.cwd,
|
||||||
|
)}.`,
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
`It is likely you do not have Tailwind CSS installed or have an invalid configuration.`,
|
||||||
|
)
|
||||||
|
logger.error(`Install Tailwind CSS then try again.`)
|
||||||
|
if (projectInfo?.framework.links.tailwind) {
|
||||||
|
logger.error(
|
||||||
|
`Visit ${highlighter.info(
|
||||||
|
projectInfo?.framework.links.tailwind,
|
||||||
|
)} to get started.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors[ERRORS.IMPORT_ALIAS_MISSING]) {
|
||||||
|
logger.break()
|
||||||
|
logger.error(`No import alias found in your tsconfig.json file.`)
|
||||||
|
if (projectInfo?.framework.links.installation) {
|
||||||
|
logger.error(
|
||||||
|
`Visit ${highlighter.info(
|
||||||
|
projectInfo?.framework.links.installation,
|
||||||
|
)} to learn how to set an import alias.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.break()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
projectInfo,
|
||||||
|
}
|
||||||
|
}
|
||||||
65
packages/cli/src/preflights/preflight-migrate.ts
Normal file
65
packages/cli/src/preflights/preflight-migrate.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import type { migrateOptionsSchema } from '@/src/commands/migrate'
|
||||||
|
import type { z } from 'zod'
|
||||||
|
import path from 'node:path'
|
||||||
|
// import { addOptionsSchema } from '@/src/commands/add'
|
||||||
|
import * as ERRORS from '@/src/utils/errors'
|
||||||
|
import { getConfig } from '@/src/utils/get-config'
|
||||||
|
import { highlighter } from '@/src/utils/highlighter'
|
||||||
|
import { logger } from '@/src/utils/logger'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
|
||||||
|
export async function preFlightMigrate(
|
||||||
|
options: z.infer<typeof migrateOptionsSchema>,
|
||||||
|
) {
|
||||||
|
const errors: Record<string, boolean> = {}
|
||||||
|
|
||||||
|
// Ensure target directory exists.
|
||||||
|
// Check for empty project. We assume if no package.json exists, the project is empty.
|
||||||
|
if (
|
||||||
|
!fs.existsSync(options.cwd)
|
||||||
|
|| !fs.existsSync(path.resolve(options.cwd, 'package.json'))
|
||||||
|
) {
|
||||||
|
errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT] = true
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
config: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing components.json file.
|
||||||
|
if (!fs.existsSync(path.resolve(options.cwd, 'components.json'))) {
|
||||||
|
errors[ERRORS.MISSING_CONFIG] = true
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
config: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getConfig(options.cwd)
|
||||||
|
|
||||||
|
return {
|
||||||
|
errors,
|
||||||
|
config: config!,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
logger.break()
|
||||||
|
logger.error(
|
||||||
|
`An invalid ${highlighter.info(
|
||||||
|
'components.json',
|
||||||
|
)} file was found at ${highlighter.info(
|
||||||
|
options.cwd,
|
||||||
|
)}.\nBefore you can run a migration, you must create a valid ${highlighter.info(
|
||||||
|
'components.json',
|
||||||
|
)} file by running the ${highlighter.info('init')} command.`,
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
`Learn more at ${highlighter.info(
|
||||||
|
'https://ui.shadcn.com/docs/components-json',
|
||||||
|
)}.`,
|
||||||
|
)
|
||||||
|
logger.break()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
56
packages/cli/src/utils/add-components.ts
Normal file
56
packages/cli/src/utils/add-components.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { Config } from '@/src/utils/get-config'
|
||||||
|
import { handleError } from '@/src/utils/handle-error'
|
||||||
|
import { logger } from '@/src/utils/logger'
|
||||||
|
import { registryResolveItemsTree } from '@/src/utils/registry'
|
||||||
|
import { spinner } from '@/src/utils/spinner'
|
||||||
|
import { updateCssVars } from '@/src/utils/updaters/update-css-vars'
|
||||||
|
import { updateDependencies } from '@/src/utils/updaters/update-dependencies'
|
||||||
|
import { updateFiles } from '@/src/utils/updaters/update-files'
|
||||||
|
import { updateTailwindConfig } from '@/src/utils/updaters/update-tailwind-config'
|
||||||
|
|
||||||
|
export async function addComponents(
|
||||||
|
components: string[],
|
||||||
|
config: Config,
|
||||||
|
options: {
|
||||||
|
overwrite?: boolean
|
||||||
|
silent?: boolean
|
||||||
|
isNewProject?: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
options = {
|
||||||
|
overwrite: false,
|
||||||
|
silent: false,
|
||||||
|
isNewProject: false,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrySpinner = spinner(`Checking registry.`, {
|
||||||
|
silent: options.silent,
|
||||||
|
})?.start()
|
||||||
|
const tree = await registryResolveItemsTree(components, config)
|
||||||
|
if (!tree) {
|
||||||
|
registrySpinner?.fail()
|
||||||
|
return handleError(new Error('Failed to fetch components from registry.'))
|
||||||
|
}
|
||||||
|
registrySpinner?.succeed()
|
||||||
|
|
||||||
|
await updateTailwindConfig(tree.tailwind?.config, config, {
|
||||||
|
silent: options.silent,
|
||||||
|
})
|
||||||
|
await updateCssVars(tree.cssVars, config, {
|
||||||
|
cleanupDefaultNextStyles: options.isNewProject,
|
||||||
|
silent: options.silent,
|
||||||
|
})
|
||||||
|
|
||||||
|
await updateDependencies(tree.dependencies, config, {
|
||||||
|
silent: options.silent,
|
||||||
|
})
|
||||||
|
await updateFiles(tree.files, config, {
|
||||||
|
overwrite: options.overwrite,
|
||||||
|
silent: options.silent,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (tree.docs) {
|
||||||
|
logger.info(tree.docs)
|
||||||
|
}
|
||||||
|
}
|
||||||
118
packages/cli/src/utils/create-project.ts
Normal file
118
packages/cli/src/utils/create-project.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import type { initOptionsSchema } from '@/src/commands/init'
|
||||||
|
import type { z } from 'zod'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { getPackageManager } from '@/src/utils/get-package-manager'
|
||||||
|
import { highlighter } from '@/src/utils/highlighter'
|
||||||
|
import { logger } from '@/src/utils/logger'
|
||||||
|
import { spinner } from '@/src/utils/spinner'
|
||||||
|
import { execa } from 'execa'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import prompts from 'prompts'
|
||||||
|
|
||||||
|
export async function createProject(options: Pick<z.infer<typeof initOptionsSchema>, 'cwd' | 'force' | 'srcDir'>) {
|
||||||
|
options = {
|
||||||
|
srcDir: false,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.force) {
|
||||||
|
const { proceed } = await prompts({
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'proceed',
|
||||||
|
message: `The path ${highlighter.info(
|
||||||
|
options.cwd,
|
||||||
|
)} does not contain a package.json file. Would you like to start a new ${highlighter.info(
|
||||||
|
'Next.js',
|
||||||
|
)} project?`,
|
||||||
|
initial: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!proceed) {
|
||||||
|
return {
|
||||||
|
projectPath: null,
|
||||||
|
projectName: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageManager = await getPackageManager(options.cwd, {
|
||||||
|
withFallback: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { name } = await prompts({
|
||||||
|
type: 'text',
|
||||||
|
name: 'name',
|
||||||
|
message: `What is your project named?`,
|
||||||
|
initial: 'my-app',
|
||||||
|
format: (value: string) => value.trim(),
|
||||||
|
validate: (value: string) =>
|
||||||
|
value.length > 128 ? `Name should be less than 128 characters.` : true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const projectPath = `${options.cwd}/${name}`
|
||||||
|
|
||||||
|
// Check if path is writable.
|
||||||
|
try {
|
||||||
|
await fs.access(options.cwd, fs.constants.W_OK)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
logger.break()
|
||||||
|
logger.error(`The path ${highlighter.info(options.cwd)} is not writable.`)
|
||||||
|
logger.error(
|
||||||
|
`It is likely you do not have write permissions for this folder or the path ${highlighter.info(
|
||||||
|
options.cwd,
|
||||||
|
)} does not exist.`,
|
||||||
|
)
|
||||||
|
logger.break()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(path.resolve(options.cwd, name, 'package.json'))) {
|
||||||
|
logger.break()
|
||||||
|
logger.error(
|
||||||
|
`A project with the name ${highlighter.info(name)} already exists.`,
|
||||||
|
)
|
||||||
|
logger.error(`Please choose a different name and try again.`)
|
||||||
|
logger.break()
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSpinner = spinner(
|
||||||
|
`Creating a new Next.js project. This may take a few minutes.`,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
// Note: pnpm fails here. Fallback to npx with --use-PACKAGE-MANAGER.
|
||||||
|
const args = [
|
||||||
|
'--tailwind',
|
||||||
|
'--eslint',
|
||||||
|
'--typescript',
|
||||||
|
'--app',
|
||||||
|
options.srcDir ? '--src-dir' : '--no-src-dir',
|
||||||
|
'--no-import-alias',
|
||||||
|
`--use-${packageManager}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execa(
|
||||||
|
'npx',
|
||||||
|
['create-next-app@14.2.16', projectPath, '--silent', ...args],
|
||||||
|
{
|
||||||
|
cwd: options.cwd,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
logger.break()
|
||||||
|
logger.error(
|
||||||
|
`Something went wrong creating a new Next.js project. Please try again.`,
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
createSpinner?.succeed('Creating a new Next.js project.')
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectPath,
|
||||||
|
projectName: name,
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/cli/src/utils/errors.ts
Normal file
12
packages/cli/src/utils/errors.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export const MISSING_DIR_OR_EMPTY_PROJECT = '1'
|
||||||
|
export const EXISTING_CONFIG = '2'
|
||||||
|
export const MISSING_CONFIG = '3'
|
||||||
|
export const FAILED_CONFIG_READ = '4'
|
||||||
|
export const TAILWIND_NOT_CONFIGURED = '5'
|
||||||
|
export const IMPORT_ALIAS_MISSING = '6'
|
||||||
|
export const UNSUPPORTED_FRAMEWORK = '7'
|
||||||
|
export const COMPONENT_URL_NOT_FOUND = '8'
|
||||||
|
export const COMPONENT_URL_UNAUTHORIZED = '9'
|
||||||
|
export const COMPONENT_URL_FORBIDDEN = '10'
|
||||||
|
export const COMPONENT_URL_BAD_REQUEST = '11'
|
||||||
|
export const COMPONENT_URL_INTERNAL_SERVER_ERROR = '12'
|
||||||
36
packages/cli/src/utils/frameworks.ts
Normal file
36
packages/cli/src/utils/frameworks.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
export const FRAMEWORKS = {
|
||||||
|
vite: {
|
||||||
|
name: 'vite',
|
||||||
|
label: 'Vite',
|
||||||
|
links: {
|
||||||
|
installation: 'https://shadcn-vue.com/docs/installation/vite',
|
||||||
|
tailwind: 'https://tailwindcss.com/docs/guides/vite',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nuxt: {
|
||||||
|
name: 'nuxt',
|
||||||
|
label: 'Nuxt',
|
||||||
|
links: {
|
||||||
|
installation: 'https://shadcn-vue.com/docs/installation/nuxt',
|
||||||
|
tailwind: 'https://tailwindcss.com/docs/guides/nuxtjs',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
astro: {
|
||||||
|
name: 'astro',
|
||||||
|
label: 'Astro',
|
||||||
|
links: {
|
||||||
|
installation: 'https://shadcn-vue.com/docs/installation/astro',
|
||||||
|
tailwind: 'https://tailwindcss.com/docs/guides/astro',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
laravel: {
|
||||||
|
name: 'laravel',
|
||||||
|
label: 'Laravel',
|
||||||
|
links: {
|
||||||
|
installation: 'https://shadcn-vue.com/docs/installation/laravel',
|
||||||
|
tailwind: 'https://tailwindcss.com/docs/guides/laravel',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type Framework = (typeof FRAMEWORKS)[keyof typeof FRAMEWORKS]
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import type { ConfigLoaderResult } from 'tsconfig-paths'
|
|
||||||
import { existsSync } from 'node:fs'
|
|
||||||
import { resolveImport } from '@/src/utils/resolve-import'
|
import { resolveImport } from '@/src/utils/resolve-import'
|
||||||
import { loadConfig as c12LoadConfig } from 'c12'
|
import { loadConfig as c12LoadConfig } from 'c12'
|
||||||
import path from 'pathe'
|
import path from 'pathe'
|
||||||
|
|
@ -9,9 +7,10 @@ import { z } from 'zod'
|
||||||
export const DEFAULT_STYLE = 'default'
|
export const DEFAULT_STYLE = 'default'
|
||||||
export const DEFAULT_COMPONENTS = '@/components'
|
export const DEFAULT_COMPONENTS = '@/components'
|
||||||
export const DEFAULT_UTILS = '@/lib/utils'
|
export const DEFAULT_UTILS = '@/lib/utils'
|
||||||
export const DEFAULT_TYPESCRIPT_CONFIG = './tsconfig.json'
|
export const DEFAULT_TAILWIND_CSS = 'app/globals.css'
|
||||||
export const DEFAULT_TAILWIND_CONFIG = 'tailwind.config.js'
|
export const DEFAULT_TAILWIND_CONFIG = 'tailwind.config.js'
|
||||||
export const DEFAULT_TAILWIND_BASE_COLOR = 'slate'
|
export const DEFAULT_TAILWIND_BASE_COLOR = 'slate'
|
||||||
|
export const DEFAULT_TYPESCRIPT_CONFIG = './tsconfig.json'
|
||||||
|
|
||||||
export const TAILWIND_CSS_PATH = {
|
export const TAILWIND_CSS_PATH = {
|
||||||
nuxt: 'assets/css/tailwind.css',
|
nuxt: 'assets/css/tailwind.css',
|
||||||
|
|
@ -25,32 +24,35 @@ export const rawConfigSchema = z
|
||||||
$schema: z.string().optional(),
|
$schema: z.string().optional(),
|
||||||
style: z.string(),
|
style: z.string(),
|
||||||
typescript: z.boolean().default(true),
|
typescript: z.boolean().default(true),
|
||||||
tsConfigPath: z.string().default(DEFAULT_TYPESCRIPT_CONFIG),
|
|
||||||
tailwind: z.object({
|
tailwind: z.object({
|
||||||
config: z.string(),
|
config: z.string(),
|
||||||
css: z.string(),
|
css: z.string(),
|
||||||
baseColor: z.string(),
|
baseColor: z.string(),
|
||||||
cssVariables: z.boolean().default(true),
|
cssVariables: z.boolean().default(true),
|
||||||
prefix: z.string().optional(),
|
prefix: z.string().default('').optional(),
|
||||||
}),
|
}),
|
||||||
framework: z.string().default('Vite'),
|
|
||||||
aliases: z.object({
|
aliases: z.object({
|
||||||
components: z.string(),
|
components: z.string(),
|
||||||
utils: z.string(),
|
utils: z.string(),
|
||||||
ui: z.string().default('').optional(),
|
ui: z.string().optional(),
|
||||||
|
lib: z.string().optional(),
|
||||||
|
hooks: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
|
iconLibrary: z.string().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
export type RawConfig = z.infer<typeof rawConfigSchema>
|
export type RawConfig = z.infer<typeof rawConfigSchema>
|
||||||
|
|
||||||
export const configSchema = rawConfigSchema
|
export const configSchema = rawConfigSchema.extend({
|
||||||
.extend({
|
|
||||||
resolvedPaths: z.object({
|
resolvedPaths: z.object({
|
||||||
|
cwd: z.string(),
|
||||||
tailwindConfig: z.string(),
|
tailwindConfig: z.string(),
|
||||||
tailwindCss: z.string(),
|
tailwindCss: z.string(),
|
||||||
utils: z.string(),
|
utils: z.string(),
|
||||||
components: z.string(),
|
components: z.string(),
|
||||||
|
lib: z.string(),
|
||||||
|
hooks: z.string(),
|
||||||
ui: z.string(),
|
ui: z.string(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
@ -60,54 +62,64 @@ export type Config = z.infer<typeof configSchema>
|
||||||
export async function getConfig(cwd: string) {
|
export async function getConfig(cwd: string) {
|
||||||
const config = await getRawConfig(cwd)
|
const config = await getRawConfig(cwd)
|
||||||
|
|
||||||
if (!config)
|
if (!config) {
|
||||||
return null
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default icon library if not provided.
|
||||||
|
if (!config.iconLibrary) {
|
||||||
|
config.iconLibrary = config.style === 'new-york' ? 'radix' : 'lucide'
|
||||||
|
}
|
||||||
|
|
||||||
return await resolveConfigPaths(cwd, config)
|
return await resolveConfigPaths(cwd, config)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveConfigPaths(cwd: string, config: RawConfig) {
|
export async function resolveConfigPaths(cwd: string, config: RawConfig) {
|
||||||
let tsConfig: ConfigLoaderResult | undefined
|
|
||||||
let tsConfigPath = path.resolve(
|
|
||||||
cwd,
|
|
||||||
config.tsConfigPath,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (config.typescript) {
|
|
||||||
// Read tsconfig.json.
|
// Read tsconfig.json.
|
||||||
tsConfig = loadConfig(tsConfigPath)
|
const tsConfig = await loadConfig(cwd)
|
||||||
// In new Vue project, tsconfig has references to tsconfig.app.json, which is causing the path not resolving correctly
|
|
||||||
// If no paths were found, try to load tsconfig.app.json.
|
|
||||||
if ('paths' in tsConfig && Object.keys(tsConfig.paths).length === 0) {
|
|
||||||
tsConfigPath = path.resolve(cwd, './tsconfig.app.json')
|
|
||||||
if (existsSync(tsConfigPath))
|
|
||||||
tsConfig = loadConfig(tsConfigPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
tsConfigPath = config.tsConfigPath.includes('tsconfig.json') ? path.resolve(cwd, './jsconfig.json') : path.resolve(cwd, config.tsConfigPath)
|
|
||||||
tsConfig = loadConfig(tsConfigPath)
|
|
||||||
}
|
|
||||||
if (tsConfig.resultType === 'failed') {
|
if (tsConfig.resultType === 'failed') {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to load ${tsConfigPath}. ${tsConfig.message ?? ''}`.trim(),
|
`Failed to load ${config.typescript ? 'tsconfig' : 'jsconfig'}.json. ${
|
||||||
|
tsConfig.message ?? ''
|
||||||
|
}`.trim(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return configSchema.parse({
|
return configSchema.parse({
|
||||||
...config,
|
...config,
|
||||||
resolvedPaths: {
|
resolvedPaths: {
|
||||||
|
cwd,
|
||||||
tailwindConfig: path.resolve(cwd, config.tailwind.config),
|
tailwindConfig: path.resolve(cwd, config.tailwind.config),
|
||||||
tailwindCss: path.resolve(cwd, config.tailwind.css),
|
tailwindCss: path.resolve(cwd, config.tailwind.css),
|
||||||
utils: resolveImport(config.aliases.utils, tsConfig),
|
utils: await resolveImport(config.aliases.utils, tsConfig),
|
||||||
components: resolveImport(config.aliases.components, tsConfig),
|
components: await resolveImport(config.aliases.components, tsConfig),
|
||||||
ui: config.aliases.ui
|
ui: config.aliases.ui
|
||||||
? resolveImport(config.aliases.ui, tsConfig)
|
? await resolveImport(config.aliases.ui, tsConfig)
|
||||||
: resolveImport(config.aliases.components, tsConfig),
|
: path.resolve(
|
||||||
|
(await resolveImport(config.aliases.components, tsConfig))
|
||||||
|
?? cwd,
|
||||||
|
'ui',
|
||||||
|
),
|
||||||
|
// TODO: Make this configurable.
|
||||||
|
// For now, we assume the lib and hooks directories are one level up from the components directory.
|
||||||
|
lib: config.aliases.lib
|
||||||
|
? await resolveImport(config.aliases.lib, tsConfig)
|
||||||
|
: path.resolve(
|
||||||
|
(await resolveImport(config.aliases.utils, tsConfig)) ?? cwd,
|
||||||
|
'..',
|
||||||
|
),
|
||||||
|
hooks: config.aliases.hooks
|
||||||
|
? await resolveImport(config.aliases.hooks, tsConfig)
|
||||||
|
: path.resolve(
|
||||||
|
(await resolveImport(config.aliases.components, tsConfig))
|
||||||
|
?? cwd,
|
||||||
|
'..',
|
||||||
|
'hooks',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRawConfig(cwd: string): Promise<RawConfig | null> {
|
export async function getRawConfig(cwd: string): Promise<RawConfig | null> {
|
||||||
try {
|
try {
|
||||||
const configResult = await c12LoadConfig({
|
const configResult = await c12LoadConfig({
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
import type { PackageJson } from 'type-fest'
|
import type { PackageJson } from 'type-fest'
|
||||||
import { fileURLToPath } from 'node:url'
|
import path from 'node:path'
|
||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import path from 'pathe'
|
|
||||||
|
|
||||||
export function getPackageInfo() {
|
export function getPackageInfo(
|
||||||
const packageJsonPath = getPackageFilePath('../package.json')
|
cwd: string = '',
|
||||||
|
shouldThrow: boolean = true,
|
||||||
|
): PackageJson | null {
|
||||||
|
const packageJsonPath = path.join(cwd, 'package.json')
|
||||||
|
|
||||||
return fs.readJSONSync(packageJsonPath) as PackageJson
|
return fs.readJSONSync(packageJsonPath, {
|
||||||
}
|
throws: shouldThrow,
|
||||||
|
}) as PackageJson
|
||||||
function getPackageFilePath(filePath: string) {
|
|
||||||
const distPath = fileURLToPath(new URL('.', import.meta.url))
|
|
||||||
|
|
||||||
return path.resolve(distPath, filePath)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,251 @@
|
||||||
import type { PackageJson } from 'pkg-types'
|
import type { Framework } from '@/src/utils/frameworks'
|
||||||
import { existsSync } from 'node:fs'
|
import type {
|
||||||
|
Config,
|
||||||
|
RawConfig,
|
||||||
|
} from '@/src/utils/get-config'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { FRAMEWORKS } from '@/src/utils/frameworks'
|
||||||
|
import {
|
||||||
|
getConfig,
|
||||||
|
resolveConfigPaths,
|
||||||
|
} from '@/src/utils/get-config'
|
||||||
|
import { getPackageInfo } from '@/src/utils/get-package-info'
|
||||||
|
import fg from 'fast-glob'
|
||||||
import fs from 'fs-extra'
|
import fs from 'fs-extra'
|
||||||
import path from 'pathe'
|
import { loadConfig } from 'tsconfig-paths'
|
||||||
import { readPackageJSON } from 'pkg-types'
|
import { z } from 'zod'
|
||||||
|
|
||||||
export async function getProjectInfo() {
|
export interface ProjectInfo {
|
||||||
const info = {
|
framework: Framework
|
||||||
tsconfig: null,
|
isSrcDir: boolean
|
||||||
isNuxt: false,
|
isRSC: boolean
|
||||||
shadcnNuxt: undefined,
|
isTsx: boolean
|
||||||
isVueVite: false,
|
tailwindConfigFile: string | null
|
||||||
srcDir: false,
|
tailwindCssFile: string | null
|
||||||
componentsUiDir: false,
|
aliasPrefix: string | null
|
||||||
srcComponentsUiDir: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const PROJECT_SHARED_IGNORE = [
|
||||||
const tsconfig = await getTsConfig()
|
'**/node_modules/**',
|
||||||
|
'.nuxt',
|
||||||
|
'public',
|
||||||
|
'dist',
|
||||||
|
'build',
|
||||||
|
]
|
||||||
|
|
||||||
const isNuxt = existsSync(path.resolve('./nuxt.config.js')) || existsSync(path.resolve('./nuxt.config.ts'))
|
const TS_CONFIG_SCHEMA = z.object({
|
||||||
const shadcnNuxt = isNuxt ? await getShadcnNuxtInfo() : undefined
|
compilerOptions: z.object({
|
||||||
|
paths: z.record(z.string().or(z.array(z.string()))),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
export async function getProjectInfo(cwd: string): Promise<ProjectInfo | null> {
|
||||||
tsconfig,
|
const [
|
||||||
isNuxt,
|
configFiles,
|
||||||
shadcnNuxt,
|
isSrcDir,
|
||||||
isVueVite: existsSync(path.resolve('./vite.config.js')) || existsSync(path.resolve('./vite.config.ts')),
|
isTsx,
|
||||||
srcDir: existsSync(path.resolve('./src')),
|
tailwindConfigFile,
|
||||||
srcComponentsUiDir: existsSync(path.resolve('./src/components/ui')),
|
tailwindCssFile,
|
||||||
componentsUiDir: existsSync(path.resolve('./components/ui')),
|
aliasPrefix,
|
||||||
}
|
packageJson,
|
||||||
}
|
] = await Promise.all([
|
||||||
catch (error) {
|
fg.glob('**/{nuxt,vite,astro}.config.*|composer.json', {
|
||||||
return info
|
cwd,
|
||||||
}
|
deep: 3,
|
||||||
|
ignore: PROJECT_SHARED_IGNORE,
|
||||||
|
}),
|
||||||
|
fs.pathExists(path.resolve(cwd, 'src')),
|
||||||
|
isTypeScriptProject(cwd),
|
||||||
|
getTailwindConfigFile(cwd),
|
||||||
|
getTailwindCssFile(cwd),
|
||||||
|
getTsConfigAliasPrefix(cwd),
|
||||||
|
getPackageInfo(cwd, false),
|
||||||
|
])
|
||||||
|
|
||||||
|
const isUsingAppDir = await fs.pathExists(
|
||||||
|
path.resolve(cwd, `${isSrcDir ? 'src/' : ''}app`),
|
||||||
|
)
|
||||||
|
|
||||||
|
const type: ProjectInfo = {
|
||||||
|
framework: FRAMEWORKS.vite, // TODO: Maybe add a manual installation
|
||||||
|
isSrcDir,
|
||||||
|
isRSC: false,
|
||||||
|
isTsx,
|
||||||
|
tailwindConfigFile,
|
||||||
|
tailwindCssFile,
|
||||||
|
aliasPrefix,
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getShadcnNuxtInfo() {
|
// Nuxt.
|
||||||
let nuxtModule: PackageJson | undefined
|
if (configFiles.find(file => file.startsWith('nuxt.config.'))?.length) {
|
||||||
try {
|
type.framework = FRAMEWORKS.nuxt
|
||||||
nuxtModule = await readPackageJSON('shadcn-nuxt')
|
return type
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
nuxtModule = undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nuxtModule
|
// Astro.
|
||||||
|
if (configFiles.find(file => file.startsWith('astro.config.'))?.length) {
|
||||||
|
type.framework = FRAMEWORKS.astro
|
||||||
|
return type
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTsConfig() {
|
// Laravel.
|
||||||
try {
|
if (configFiles.find(file => file.startsWith('composer.json'))?.length) {
|
||||||
const tsconfigPath = path.join('tsconfig.json')
|
type.framework = FRAMEWORKS.laravel
|
||||||
const tsconfig = await fs.readJSON(tsconfigPath)
|
return type
|
||||||
|
|
||||||
if (!tsconfig)
|
|
||||||
throw new Error('tsconfig.json is missing')
|
|
||||||
|
|
||||||
return tsconfig
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
|
||||||
|
// Vite.
|
||||||
|
// We'll assume that it got caught by the Remix check above.
|
||||||
|
if (configFiles.find(file => file.startsWith('vite.config.'))?.length) {
|
||||||
|
type.framework = FRAMEWORKS.vite
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
|
||||||
|
return type
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTailwindCssFile(cwd: string) {
|
||||||
|
const files = await fg.glob(['**/*.css', '**/*.scss'], {
|
||||||
|
cwd,
|
||||||
|
deep: 5,
|
||||||
|
ignore: PROJECT_SHARED_IGNORE,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const contents = await fs.readFile(path.resolve(cwd, file), 'utf8')
|
||||||
|
// Assume that if the file contains `@tailwind base` it's the main css file.
|
||||||
|
if (contents.includes('@tailwind base')) {
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTailwindConfigFile(cwd: string) {
|
||||||
|
const files = await fg.glob('tailwind.config.*', {
|
||||||
|
cwd,
|
||||||
|
deep: 3,
|
||||||
|
ignore: PROJECT_SHARED_IGNORE,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!files.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return files[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTsConfigAliasPrefix(cwd: string) {
|
||||||
|
const tsConfig = await loadConfig(cwd)
|
||||||
|
|
||||||
|
if (
|
||||||
|
tsConfig?.resultType === 'failed'
|
||||||
|
|| !Object.entries(tsConfig?.paths).length
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// This assume that the first alias is the prefix.
|
||||||
|
for (const [alias, paths] of Object.entries(tsConfig.paths)) {
|
||||||
|
if (
|
||||||
|
paths.includes('./*')
|
||||||
|
|| paths.includes('./src/*')
|
||||||
|
|| paths.includes('./app/*')
|
||||||
|
|| paths.includes('./resources/js/*') // Laravel.
|
||||||
|
) {
|
||||||
|
return alias.replace(/\/\*$/, '') ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first alias as the prefix.
|
||||||
|
return Object.keys(tsConfig?.paths)?.[0].replace(/\/\*$/, '') ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isTypeScriptProject(cwd: string) {
|
||||||
|
const files = await fg.glob('tsconfig.*', {
|
||||||
|
cwd,
|
||||||
|
deep: 1,
|
||||||
|
ignore: PROJECT_SHARED_IGNORE,
|
||||||
|
})
|
||||||
|
|
||||||
|
return files.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTsConfig(cwd: string) {
|
||||||
|
for (const fallback of [
|
||||||
|
'tsconfig.json',
|
||||||
|
'tsconfig.web.json',
|
||||||
|
'tsconfig.app.json',
|
||||||
|
]) {
|
||||||
|
const filePath = path.resolve(cwd, fallback)
|
||||||
|
if (!(await fs.pathExists(filePath))) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can't use fs.readJSON because it doesn't support comments.
|
||||||
|
const contents = await fs.readFile(filePath, 'utf8')
|
||||||
|
const cleanedContents = contents.replace(/\/\*\s*\*\//g, '')
|
||||||
|
const result = TS_CONFIG_SCHEMA.safeParse(JSON.parse(cleanedContents))
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjectConfig(
|
||||||
|
cwd: string,
|
||||||
|
defaultProjectInfo: ProjectInfo | null = null,
|
||||||
|
): Promise<Config | null> {
|
||||||
|
// Check for existing component config.
|
||||||
|
const [existingConfig, projectInfo] = await Promise.all([
|
||||||
|
getConfig(cwd),
|
||||||
|
!defaultProjectInfo
|
||||||
|
? getProjectInfo(cwd)
|
||||||
|
: Promise.resolve(defaultProjectInfo),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (existingConfig) {
|
||||||
|
return existingConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!projectInfo
|
||||||
|
|| !projectInfo.tailwindConfigFile
|
||||||
|
|| !projectInfo.tailwindCssFile
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: RawConfig = {
|
||||||
|
$schema: 'https://shadcn-vue.com/schema.json',
|
||||||
|
typescript: true,
|
||||||
|
style: 'new-york',
|
||||||
|
tailwind: {
|
||||||
|
config: projectInfo.tailwindConfigFile,
|
||||||
|
baseColor: 'zinc',
|
||||||
|
css: projectInfo.tailwindCssFile,
|
||||||
|
cssVariables: true,
|
||||||
|
prefix: '',
|
||||||
|
},
|
||||||
|
iconLibrary: 'lucide',
|
||||||
|
aliases: {
|
||||||
|
components: `${projectInfo.aliasPrefix}/components`,
|
||||||
|
ui: `${projectInfo.aliasPrefix}/components/ui`,
|
||||||
|
hooks: `${projectInfo.aliasPrefix}/hooks`,
|
||||||
|
lib: `${projectInfo.aliasPrefix}/lib`,
|
||||||
|
utils: `${projectInfo.aliasPrefix}/lib/utils`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return await resolveConfigPaths(cwd, config)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
packages/cli/src/utils/highlighter.ts
Normal file
8
packages/cli/src/utils/highlighter.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { cyan, green, red, yellow } from 'kleur/colors'
|
||||||
|
|
||||||
|
export const highlighter = {
|
||||||
|
error: red,
|
||||||
|
warn: yellow,
|
||||||
|
info: cyan,
|
||||||
|
success: green,
|
||||||
|
}
|
||||||
12
packages/cli/src/utils/icon-libraries.ts
Normal file
12
packages/cli/src/utils/icon-libraries.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export const ICON_LIBRARIES = {
|
||||||
|
lucide: {
|
||||||
|
name: 'lucide-vue-next',
|
||||||
|
package: 'lucide-vue-next',
|
||||||
|
import: 'lucide-vue-next',
|
||||||
|
},
|
||||||
|
radix: {
|
||||||
|
name: '@radix-icons/vue',
|
||||||
|
package: '@radix-icons/vue',
|
||||||
|
import: '@radix-icons/vue',
|
||||||
|
},
|
||||||
|
}
|
||||||
23
packages/cli/src/utils/logger.ts
Normal file
23
packages/cli/src/utils/logger.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { highlighter } from '@/src/utils/highlighter'
|
||||||
|
import consola from 'consola'
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
error(...args: unknown[]) {
|
||||||
|
consola.log(highlighter.error(args.join(' ')))
|
||||||
|
},
|
||||||
|
warn(...args: unknown[]) {
|
||||||
|
consola.log(highlighter.warn(args.join(' ')))
|
||||||
|
},
|
||||||
|
info(...args: unknown[]) {
|
||||||
|
consola.log(highlighter.info(args.join(' ')))
|
||||||
|
},
|
||||||
|
success(...args: unknown[]) {
|
||||||
|
consola.log(highlighter.success(args.join(' ')))
|
||||||
|
},
|
||||||
|
log(...args: unknown[]) {
|
||||||
|
consola.log(args.join(' '))
|
||||||
|
},
|
||||||
|
break() {
|
||||||
|
consola.log('')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,27 @@
|
||||||
import type { Config } from '@/src/utils/get-config'
|
import type { Config } from '@/src/utils/get-config'
|
||||||
import type { registryItemWithContentSchema } from '@/src/utils/registry/schema'
|
import type {
|
||||||
import type * as z from 'zod'
|
registryItemFileSchema,
|
||||||
import process from 'node:process'
|
} from '@/src/utils/registry/schema'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { handleError } from '@/src/utils/handle-error'
|
||||||
|
import { highlighter } from '@/src/utils/highlighter'
|
||||||
|
import { logger } from '@/src/utils/logger'
|
||||||
import {
|
import {
|
||||||
|
iconsSchema,
|
||||||
registryBaseColorSchema,
|
registryBaseColorSchema,
|
||||||
registryIndexSchema,
|
registryIndexSchema,
|
||||||
registryWithContentSchema,
|
registryItemSchema,
|
||||||
|
registryResolvedItemsTreeSchema,
|
||||||
stylesSchema,
|
stylesSchema,
|
||||||
} from '@/src/utils/registry/schema'
|
} from '@/src/utils/registry/schema'
|
||||||
import consola from 'consola'
|
import { buildTailwindThemeColorsFromCssVars } from '@/src/utils/updaters/update-tailwind-config'
|
||||||
|
import deepmerge from 'deepmerge'
|
||||||
import { HttpsProxyAgent } from 'https-proxy-agent'
|
import { HttpsProxyAgent } from 'https-proxy-agent'
|
||||||
import { ofetch } from 'ofetch'
|
import { ofetch } from 'ofetch'
|
||||||
import path from 'pathe'
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const REGISTRY_URL = process.env.REGISTRY_URL ?? 'https://shadcn-vue.com/r'
|
||||||
|
|
||||||
const baseUrl = process.env.COMPONENTS_REGISTRY_URL ?? 'https://www.shadcn-vue.com'
|
|
||||||
const agent = process.env.https_proxy
|
const agent = process.env.https_proxy
|
||||||
? new HttpsProxyAgent(process.env.https_proxy)
|
? new HttpsProxyAgent(process.env.https_proxy)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
@ -25,7 +33,8 @@ export async function getRegistryIndex() {
|
||||||
return registryIndexSchema.parse(result)
|
return registryIndexSchema.parse(result)
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
throw new Error('Failed to fetch components from registry.')
|
logger.error('\n')
|
||||||
|
handleError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,15 +45,43 @@ export async function getRegistryStyles() {
|
||||||
return stylesSchema.parse(result)
|
return stylesSchema.parse(result)
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
throw new Error('Failed to fetch styles from registry.')
|
logger.error('\n')
|
||||||
|
handleError(error)
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRegistryBaseColors() {
|
export async function getRegistryIcons() {
|
||||||
|
try {
|
||||||
|
const [result] = await fetchRegistry(['icons/index.json'])
|
||||||
|
return iconsSchema.parse(result)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRegistryItem(name: string, style: string) {
|
||||||
|
try {
|
||||||
|
const [result] = await fetchRegistry([
|
||||||
|
isUrl(name) ? name : `styles/${style}/${name}.json`,
|
||||||
|
])
|
||||||
|
|
||||||
|
return registryItemSchema.parse(result)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
logger.break()
|
||||||
|
handleError(error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRegistryBaseColors() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: 'slate',
|
name: 'neutral',
|
||||||
label: 'Slate',
|
label: 'Neutral',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'gray',
|
name: 'gray',
|
||||||
|
|
@ -54,14 +91,14 @@ export function getRegistryBaseColors() {
|
||||||
name: 'zinc',
|
name: 'zinc',
|
||||||
label: 'Zinc',
|
label: 'Zinc',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'neutral',
|
|
||||||
label: 'Neutral',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'stone',
|
name: 'stone',
|
||||||
label: 'Stone',
|
label: 'Stone',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'slate',
|
||||||
|
label: 'Slate',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,7 +109,7 @@ export async function getRegistryBaseColor(baseColor: string) {
|
||||||
return registryBaseColorSchema.parse(result)
|
return registryBaseColorSchema.parse(result)
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
throw new Error('Failed to fetch base color from registry.')
|
handleError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -85,8 +122,9 @@ export async function resolveTree(
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const entry = index.find(entry => entry.name === name)
|
const entry = index.find(entry => entry.name === name)
|
||||||
|
|
||||||
if (!entry)
|
if (!entry) {
|
||||||
continue
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
tree.push(entry)
|
tree.push(entry)
|
||||||
|
|
||||||
|
|
@ -109,29 +147,30 @@ export async function fetchTree(
|
||||||
try {
|
try {
|
||||||
const paths = tree.map(item => `styles/${style}/${item.name}.json`)
|
const paths = tree.map(item => `styles/${style}/${item.name}.json`)
|
||||||
const result = await fetchRegistry(paths)
|
const result = await fetchRegistry(paths)
|
||||||
|
return registryIndexSchema.parse(result)
|
||||||
return registryWithContentSchema.parse(result)
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
throw new Error('Failed to fetch tree from registry.')
|
handleError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getItemTargetPath(
|
export async function getItemTargetPath(
|
||||||
config: Config,
|
config: Config,
|
||||||
item: Pick<z.infer<typeof registryItemWithContentSchema>, 'type'>,
|
item: Pick<z.infer<typeof registryItemSchema>, 'type'>,
|
||||||
override?: string,
|
override?: string,
|
||||||
) {
|
) {
|
||||||
// Allow overrides for all items but ui.
|
if (override) {
|
||||||
if (override)
|
|
||||||
return override
|
return override
|
||||||
|
}
|
||||||
|
|
||||||
if (item.type === 'components:ui' && config.aliases.ui)
|
if (item.type === 'registry:ui') {
|
||||||
return config.resolvedPaths.ui
|
return config.resolvedPaths.ui ?? config.resolvedPaths.components
|
||||||
|
}
|
||||||
|
|
||||||
const [parent, type] = item.type.split(':')
|
const [parent, type] = item.type?.split(':') ?? []
|
||||||
if (!(parent in config.resolvedPaths))
|
if (!(parent in config.resolvedPaths)) {
|
||||||
return null
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return path.join(
|
return path.join(
|
||||||
config.resolvedPaths[parent as keyof typeof config.resolvedPaths],
|
config.resolvedPaths[parent as keyof typeof config.resolvedPaths],
|
||||||
|
|
@ -143,18 +182,295 @@ async function fetchRegistry(paths: string[]) {
|
||||||
try {
|
try {
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
paths.map(async (path) => {
|
paths.map(async (path) => {
|
||||||
const response = await ofetch(`${baseUrl}/registry/${path}`, {
|
const url = getRegistryUrl(path)
|
||||||
// @ts-expect-error agent type
|
const response = await ofetch(url, { agent })
|
||||||
agent,
|
|
||||||
})
|
|
||||||
|
|
||||||
return response
|
if (!response.ok) {
|
||||||
|
const errorMessages: { [key: number]: string } = {
|
||||||
|
400: 'Bad request',
|
||||||
|
401: 'Unauthorized',
|
||||||
|
403: 'Forbidden',
|
||||||
|
404: 'Not found',
|
||||||
|
500: 'Internal server error',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error(
|
||||||
|
`You are not authorized to access the component at ${highlighter.info(
|
||||||
|
url,
|
||||||
|
)}.\nIf this is a remote registry, you may need to authenticate.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error(
|
||||||
|
`The component at ${highlighter.info(
|
||||||
|
url,
|
||||||
|
)} was not found.\nIt may not exist at the registry. Please make sure it is a valid component.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 403) {
|
||||||
|
throw new Error(
|
||||||
|
`You do not have access to the component at ${highlighter.info(
|
||||||
|
url,
|
||||||
|
)}.\nIf this is a remote registry, you may need to authenticate or a token.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
const message
|
||||||
|
= result && typeof result === 'object' && 'error' in result
|
||||||
|
? result.error
|
||||||
|
: response.statusText || errorMessages[response.status]
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch from ${highlighter.info(url)}.\n${message}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
consola.error(error)
|
logger.error('\n')
|
||||||
throw new Error(`Failed to fetch registry from ${baseUrl}.`)
|
handleError(error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegistryItemFileTargetPath(
|
||||||
|
file: z.infer<typeof registryItemFileSchema>,
|
||||||
|
config: Config,
|
||||||
|
override?: string,
|
||||||
|
) {
|
||||||
|
if (override) {
|
||||||
|
return override
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type === 'registry:ui') {
|
||||||
|
return config.resolvedPaths.ui
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type === 'registry:lib') {
|
||||||
|
return config.resolvedPaths.lib
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type === 'registry:block' || file.type === 'registry:component') {
|
||||||
|
return config.resolvedPaths.components
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type === 'registry:hook') {
|
||||||
|
return config.resolvedPaths.hooks
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: we put this in components for now.
|
||||||
|
// We should move this to pages as per framework.
|
||||||
|
if (file.type === 'registry:page') {
|
||||||
|
return config.resolvedPaths.components
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.resolvedPaths.components
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registryResolveItemsTree(
|
||||||
|
names: z.infer<typeof registryItemSchema>['name'][],
|
||||||
|
config: Config,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const index = await getRegistryIndex()
|
||||||
|
if (!index) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're resolving the index, we want it to go first.
|
||||||
|
if (names.includes('index')) {
|
||||||
|
names.unshift('index')
|
||||||
|
}
|
||||||
|
|
||||||
|
const registryDependencies: string[] = []
|
||||||
|
for (const name of names) {
|
||||||
|
const itemRegistryDependencies = await resolveRegistryDependencies(
|
||||||
|
name,
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
registryDependencies.push(...itemRegistryDependencies)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueRegistryDependencies = Array.from(new Set(registryDependencies))
|
||||||
|
const result = await fetchRegistry(uniqueRegistryDependencies)
|
||||||
|
const payload = z.array(registryItemSchema).parse(result)
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're resolving the index, we want to fetch
|
||||||
|
// the theme item if a base color is provided.
|
||||||
|
// We do this for index only.
|
||||||
|
// Other components will ship with their theme tokens.
|
||||||
|
if (names.includes('index')) {
|
||||||
|
if (config.tailwind.baseColor) {
|
||||||
|
const theme = await registryGetTheme(config.tailwind.baseColor, config)
|
||||||
|
if (theme) {
|
||||||
|
payload.unshift(theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tailwind = {}
|
||||||
|
payload.forEach((item) => {
|
||||||
|
tailwind = deepmerge(tailwind, item.tailwind ?? {})
|
||||||
|
})
|
||||||
|
|
||||||
|
let cssVars = {}
|
||||||
|
payload.forEach((item) => {
|
||||||
|
cssVars = deepmerge(cssVars, item.cssVars ?? {})
|
||||||
|
})
|
||||||
|
|
||||||
|
let docs = ''
|
||||||
|
payload.forEach((item) => {
|
||||||
|
if (item.docs) {
|
||||||
|
docs += `${item.docs}\n`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return registryResolvedItemsTreeSchema.parse({
|
||||||
|
dependencies: deepmerge.all(
|
||||||
|
payload.map(item => item.dependencies ?? []),
|
||||||
|
),
|
||||||
|
devDependencies: deepmerge.all(
|
||||||
|
payload.map(item => item.devDependencies ?? []),
|
||||||
|
),
|
||||||
|
files: deepmerge.all(payload.map(item => item.files ?? [])),
|
||||||
|
tailwind,
|
||||||
|
cssVars,
|
||||||
|
docs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRegistryDependencies(
|
||||||
|
url: string,
|
||||||
|
config: Config,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const visited = new Set<string>()
|
||||||
|
const payload: string[] = []
|
||||||
|
|
||||||
|
async function resolveDependencies(itemUrl: string) {
|
||||||
|
const url = getRegistryUrl(
|
||||||
|
isUrl(itemUrl) ? itemUrl : `styles/${config.style}/${itemUrl}.json`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (visited.has(url)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(url)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [result] = await fetchRegistry([url])
|
||||||
|
const item = registryItemSchema.parse(result)
|
||||||
|
payload.push(url)
|
||||||
|
|
||||||
|
if (item.registryDependencies) {
|
||||||
|
for (const dependency of item.registryDependencies) {
|
||||||
|
await resolveDependencies(dependency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Error fetching or parsing registry item at ${itemUrl}:`,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await resolveDependencies(url)
|
||||||
|
return Array.from(new Set(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registryGetTheme(name: string, config: Config) {
|
||||||
|
const baseColor = await getRegistryBaseColor(name)
|
||||||
|
if (!baseColor) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Move this to the registry i.e registry:theme.
|
||||||
|
const theme = {
|
||||||
|
name,
|
||||||
|
type: 'registry:theme',
|
||||||
|
tailwind: {
|
||||||
|
config: {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
colors: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cssVars: {
|
||||||
|
light: {
|
||||||
|
radius: '0.5rem',
|
||||||
|
},
|
||||||
|
dark: {},
|
||||||
|
},
|
||||||
|
} satisfies z.infer<typeof registryItemSchema>
|
||||||
|
|
||||||
|
if (config.tailwind.cssVariables) {
|
||||||
|
theme.tailwind.config.theme.extend.colors = {
|
||||||
|
...theme.tailwind.config.theme.extend.colors,
|
||||||
|
...buildTailwindThemeColorsFromCssVars(baseColor.cssVars.dark),
|
||||||
|
}
|
||||||
|
theme.cssVars = {
|
||||||
|
light: {
|
||||||
|
...baseColor.cssVars.light,
|
||||||
|
...theme.cssVars.light,
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
...baseColor.cssVars.dark,
|
||||||
|
...theme.cssVars.dark,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return theme
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegistryUrl(path: string) {
|
||||||
|
if (isUrl(path)) {
|
||||||
|
// If the url contains /chat/b/, we assume it's the v0 registry.
|
||||||
|
// We need to add the /json suffix if it's missing.
|
||||||
|
const url = new URL(path)
|
||||||
|
if (url.pathname.match(/\/chat\/b\//) && !url.pathname.endsWith('/json')) {
|
||||||
|
url.pathname = `${url.pathname}/json`
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${REGISTRY_URL}/${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUrl(path: string) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-new
|
||||||
|
new URL(path)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,61 @@
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
// TODO: Extract this to a shared package.
|
// TODO: Extract this to a shared package.
|
||||||
|
export const registryItemTypeSchema = z.enum([
|
||||||
|
'registry:style',
|
||||||
|
'registry:lib',
|
||||||
|
'registry:example',
|
||||||
|
'registry:block',
|
||||||
|
'registry:component',
|
||||||
|
'registry:ui',
|
||||||
|
'registry:hook',
|
||||||
|
'registry:theme',
|
||||||
|
'registry:page',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const registryItemFileSchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
content: z.string().optional(),
|
||||||
|
type: registryItemTypeSchema,
|
||||||
|
target: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const registryItemTailwindSchema = z.object({
|
||||||
|
config: z
|
||||||
|
.object({
|
||||||
|
content: z.array(z.string()).optional(),
|
||||||
|
theme: z.record(z.string(), z.any()).optional(),
|
||||||
|
plugins: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const registryItemCssVarsSchema = z.object({
|
||||||
|
light: z.record(z.string(), z.string()).optional(),
|
||||||
|
dark: z.record(z.string(), z.string()).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export const registryItemSchema = z.object({
|
export const registryItemSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
type: registryItemTypeSchema,
|
||||||
|
description: z.string().optional(),
|
||||||
dependencies: z.array(z.string()).optional(),
|
dependencies: z.array(z.string()).optional(),
|
||||||
devDependencies: z.array(z.string()).optional(),
|
devDependencies: z.array(z.string()).optional(),
|
||||||
registryDependencies: z.array(z.string()).optional(),
|
registryDependencies: z.array(z.string()).optional(),
|
||||||
files: z.array(z.string()),
|
files: z.array(registryItemFileSchema).optional(),
|
||||||
type: z.enum(['components:ui', 'components:component', 'components:example']),
|
tailwind: registryItemTailwindSchema.optional(),
|
||||||
|
cssVars: registryItemCssVarsSchema.optional(),
|
||||||
|
meta: z.record(z.string(), z.any()).optional(),
|
||||||
|
docs: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const registryIndexSchema = z.array(registryItemSchema)
|
export type RegistryItem = z.infer<typeof registryItemSchema>
|
||||||
|
|
||||||
export const registryItemWithContentSchema = registryItemSchema.extend({
|
export const registryIndexSchema = z.array(
|
||||||
files: z.array(
|
registryItemSchema.extend({
|
||||||
z.object({
|
files: z.array(z.union([z.string(), registryItemFileSchema])).optional(),
|
||||||
name: z.string(),
|
|
||||||
content: z.string(),
|
|
||||||
}),
|
}),
|
||||||
),
|
)
|
||||||
})
|
|
||||||
|
|
||||||
export const registryWithContentSchema = z.array(registryItemWithContentSchema)
|
|
||||||
|
|
||||||
export const stylesSchema = z.array(
|
export const stylesSchema = z.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|
@ -30,6 +64,11 @@ export const stylesSchema = z.array(
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const iconsSchema = z.record(
|
||||||
|
z.string(),
|
||||||
|
z.record(z.string(), z.string()),
|
||||||
|
)
|
||||||
|
|
||||||
export const registryBaseColorSchema = z.object({
|
export const registryBaseColorSchema = z.object({
|
||||||
inlineColors: z.object({
|
inlineColors: z.object({
|
||||||
light: z.record(z.string(), z.string()),
|
light: z.record(z.string(), z.string()),
|
||||||
|
|
@ -42,3 +81,12 @@ export const registryBaseColorSchema = z.object({
|
||||||
inlineColorsTemplate: z.string(),
|
inlineColorsTemplate: z.string(),
|
||||||
cssVarsTemplate: z.string(),
|
cssVarsTemplate: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const registryResolvedItemsTreeSchema = registryItemSchema.pick({
|
||||||
|
dependencies: true,
|
||||||
|
devDependencies: true,
|
||||||
|
files: true,
|
||||||
|
tailwind: true,
|
||||||
|
cssVars: true,
|
||||||
|
docs: true,
|
||||||
|
})
|
||||||
|
|
|
||||||
13
packages/cli/src/utils/spinner.ts
Normal file
13
packages/cli/src/utils/spinner.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import ora, { type Options } from 'ora'
|
||||||
|
|
||||||
|
export function spinner(
|
||||||
|
text: Options['text'],
|
||||||
|
options?: {
|
||||||
|
silent?: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return ora({
|
||||||
|
text,
|
||||||
|
isSilent: options?.silent,
|
||||||
|
})
|
||||||
|
}
|
||||||
298
packages/cli/src/utils/updaters/update-css-vars.ts
Normal file
298
packages/cli/src/utils/updaters/update-css-vars.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
import type { Config } from '@/src/utils/get-config'
|
||||||
|
import type { registryItemCssVarsSchema } from '@/src/utils/registry/schema'
|
||||||
|
import type Root from 'postcss/lib/root'
|
||||||
|
import type Rule from 'postcss/lib/rule'
|
||||||
|
import type { z } from 'zod'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { highlighter } from '@/src/utils/highlighter'
|
||||||
|
import { spinner } from '@/src/utils/spinner'
|
||||||
|
import postcss from 'postcss'
|
||||||
|
import AtRule from 'postcss/lib/at-rule'
|
||||||
|
|
||||||
|
export async function updateCssVars(
|
||||||
|
cssVars: z.infer<typeof registryItemCssVarsSchema> | undefined,
|
||||||
|
config: Config,
|
||||||
|
options: {
|
||||||
|
cleanupDefaultNextStyles?: boolean
|
||||||
|
silent?: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!cssVars
|
||||||
|
|| !Object.keys(cssVars).length
|
||||||
|
|| !config.resolvedPaths.tailwindCss
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options = {
|
||||||
|
cleanupDefaultNextStyles: false,
|
||||||
|
silent: false,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
const cssFilepath = config.resolvedPaths.tailwindCss
|
||||||
|
const cssFilepathRelative = path.relative(
|
||||||
|
config.resolvedPaths.cwd,
|
||||||
|
cssFilepath,
|
||||||
|
)
|
||||||
|
const cssVarsSpinner = spinner(
|
||||||
|
`Updating ${highlighter.info(cssFilepathRelative)}`,
|
||||||
|
{
|
||||||
|
silent: options.silent,
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
const raw = await fs.readFile(cssFilepath, 'utf8')
|
||||||
|
const output = await transformCssVars(raw, cssVars, config, {
|
||||||
|
cleanupDefaultNextStyles: options.cleanupDefaultNextStyles,
|
||||||
|
})
|
||||||
|
await fs.writeFile(cssFilepath, output, 'utf8')
|
||||||
|
cssVarsSpinner.succeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transformCssVars(
|
||||||
|
input: string,
|
||||||
|
cssVars: z.infer<typeof registryItemCssVarsSchema>,
|
||||||
|
config: Config,
|
||||||
|
options: {
|
||||||
|
cleanupDefaultNextStyles?: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
options = {
|
||||||
|
cleanupDefaultNextStyles: false,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins = [updateCssVarsPlugin(cssVars)]
|
||||||
|
if (options.cleanupDefaultNextStyles) {
|
||||||
|
plugins.push(cleanupDefaultNextStylesPlugin())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add the base layer plugin if we're using css variables.
|
||||||
|
if (config.tailwind.cssVariables) {
|
||||||
|
plugins.push(updateBaseLayerPlugin())
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await postcss(plugins).process(input, {
|
||||||
|
from: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result.css
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBaseLayerPlugin() {
|
||||||
|
return {
|
||||||
|
postcssPlugin: 'update-base-layer',
|
||||||
|
Once(root: Root) {
|
||||||
|
const requiredRules = [
|
||||||
|
{ selector: '*', apply: 'border-border' },
|
||||||
|
{ selector: 'body', apply: 'bg-background text-foreground' },
|
||||||
|
]
|
||||||
|
|
||||||
|
let baseLayer = root.nodes.find(
|
||||||
|
(node): node is AtRule =>
|
||||||
|
node.type === 'atrule'
|
||||||
|
&& node.name === 'layer'
|
||||||
|
&& node.params === 'base'
|
||||||
|
&& requiredRules.every(({ selector, apply }) =>
|
||||||
|
node.nodes?.some(
|
||||||
|
(rule): rule is Rule =>
|
||||||
|
rule.type === 'rule'
|
||||||
|
&& rule.selector === selector
|
||||||
|
&& rule.nodes.some(
|
||||||
|
(applyRule): applyRule is AtRule =>
|
||||||
|
applyRule.type === 'atrule'
|
||||||
|
&& applyRule.name === 'apply'
|
||||||
|
&& applyRule.params === apply,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) as AtRule | undefined
|
||||||
|
|
||||||
|
if (!baseLayer) {
|
||||||
|
baseLayer = postcss.atRule({
|
||||||
|
name: 'layer',
|
||||||
|
params: 'base',
|
||||||
|
raws: { semicolon: true, between: ' ', before: '\n' },
|
||||||
|
})
|
||||||
|
root.append(baseLayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
requiredRules.forEach(({ selector, apply }) => {
|
||||||
|
const existingRule = baseLayer?.nodes?.find(
|
||||||
|
(node): node is Rule =>
|
||||||
|
node.type === 'rule' && node.selector === selector,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!existingRule) {
|
||||||
|
baseLayer?.append(
|
||||||
|
postcss.rule({
|
||||||
|
selector,
|
||||||
|
nodes: [
|
||||||
|
postcss.atRule({
|
||||||
|
name: 'apply',
|
||||||
|
params: apply,
|
||||||
|
raws: { semicolon: true, before: '\n ' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
raws: { semicolon: true, between: ' ', before: '\n ' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCssVarsPlugin(
|
||||||
|
cssVars: z.infer<typeof registryItemCssVarsSchema>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
postcssPlugin: 'update-css-vars',
|
||||||
|
Once(root: Root) {
|
||||||
|
let baseLayer = root.nodes.find(
|
||||||
|
node =>
|
||||||
|
node.type === 'atrule'
|
||||||
|
&& node.name === 'layer'
|
||||||
|
&& node.params === 'base',
|
||||||
|
) as AtRule | undefined
|
||||||
|
|
||||||
|
if (!(baseLayer instanceof AtRule)) {
|
||||||
|
baseLayer = postcss.atRule({
|
||||||
|
name: 'layer',
|
||||||
|
params: 'base',
|
||||||
|
nodes: [],
|
||||||
|
raws: {
|
||||||
|
semicolon: true,
|
||||||
|
before: '\n',
|
||||||
|
between: ' ',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
root.append(baseLayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseLayer !== undefined) {
|
||||||
|
// Add variables for each key in cssVars
|
||||||
|
Object.entries(cssVars).forEach(([key, vars]) => {
|
||||||
|
const selector = key === 'light' ? ':root' : `.${key}`
|
||||||
|
// TODO: Fix typecheck.
|
||||||
|
addOrUpdateVars(baseLayer as AtRule, selector, vars)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeConflictVars(root: Rule | Root) {
|
||||||
|
const rootRule = root.nodes.find(
|
||||||
|
(node): node is Rule => node.type === 'rule' && node.selector === ':root',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (rootRule) {
|
||||||
|
const propsToRemove = ['--background', '--foreground']
|
||||||
|
|
||||||
|
rootRule.nodes
|
||||||
|
.filter(
|
||||||
|
(node): node is postcss.Declaration =>
|
||||||
|
node.type === 'decl' && propsToRemove.includes(node.prop),
|
||||||
|
)
|
||||||
|
.forEach(node => node.remove())
|
||||||
|
|
||||||
|
if (rootRule.nodes.length === 0) {
|
||||||
|
rootRule.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupDefaultNextStylesPlugin() {
|
||||||
|
return {
|
||||||
|
postcssPlugin: 'cleanup-default-next-styles',
|
||||||
|
Once(root: Root) {
|
||||||
|
const bodyRule = root.nodes.find(
|
||||||
|
(node): node is Rule => node.type === 'rule' && node.selector === 'body',
|
||||||
|
)
|
||||||
|
if (bodyRule) {
|
||||||
|
// Remove color from the body node.
|
||||||
|
bodyRule.nodes
|
||||||
|
.find(
|
||||||
|
(node): node is postcss.Declaration =>
|
||||||
|
node.type === 'decl'
|
||||||
|
&& node.prop === 'color'
|
||||||
|
&& ['rgb(var(--foreground-rgb))', 'var(--foreground)'].includes(
|
||||||
|
node.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
?.remove()
|
||||||
|
|
||||||
|
// Remove background: linear-gradient.
|
||||||
|
bodyRule.nodes
|
||||||
|
.find((node): node is postcss.Declaration => {
|
||||||
|
return (
|
||||||
|
node.type === 'decl'
|
||||||
|
&& node.prop === 'background'
|
||||||
|
// This is only going to run on create project, so all good.
|
||||||
|
&& (node.value.startsWith('linear-gradient')
|
||||||
|
|| node.value === 'var(--background)')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
?.remove()
|
||||||
|
|
||||||
|
// If the body rule is empty, remove it.
|
||||||
|
if (bodyRule.nodes.length === 0) {
|
||||||
|
bodyRule.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeConflictVars(root)
|
||||||
|
|
||||||
|
const darkRootRule = root.nodes.find(
|
||||||
|
(node): node is Rule =>
|
||||||
|
node.type === 'atrule'
|
||||||
|
&& node.params === '(prefers-color-scheme: dark)',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (darkRootRule) {
|
||||||
|
removeConflictVars(darkRootRule)
|
||||||
|
if (darkRootRule.nodes.length === 0) {
|
||||||
|
darkRootRule.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOrUpdateVars(
|
||||||
|
baseLayer: AtRule,
|
||||||
|
selector: string,
|
||||||
|
vars: Record<string, string>,
|
||||||
|
) {
|
||||||
|
let ruleNode = baseLayer.nodes?.find(
|
||||||
|
(node): node is Rule => node.type === 'rule' && node.selector === selector,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!ruleNode) {
|
||||||
|
if (Object.keys(vars).length > 0) {
|
||||||
|
ruleNode = postcss.rule({
|
||||||
|
selector,
|
||||||
|
raws: { between: ' ', before: '\n ' },
|
||||||
|
})
|
||||||
|
baseLayer.append(ruleNode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(vars).forEach(([key, value]) => {
|
||||||
|
const prop = `--${key.replace(/^--/, '')}`
|
||||||
|
const newDecl = postcss.decl({
|
||||||
|
prop,
|
||||||
|
value,
|
||||||
|
raws: { semicolon: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const existingDecl = ruleNode?.nodes.find(
|
||||||
|
(node): node is postcss.Declaration =>
|
||||||
|
node.type === 'decl' && node.prop === prop,
|
||||||
|
)
|
||||||
|
|
||||||
|
existingDecl ? existingDecl.replaceWith(newDecl) : ruleNode?.append(newDecl)
|
||||||
|
})
|
||||||
|
}
|
||||||
28
packages/cli/src/utils/updaters/update-dependencies.ts
Normal file
28
packages/cli/src/utils/updaters/update-dependencies.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { Config } from '@/src/utils/get-config'
|
||||||
|
import type { RegistryItem } from '@/src/utils/registry/schema'
|
||||||
|
import { spinner } from '@/src/utils/spinner'
|
||||||
|
import { addDependency } from 'nypm'
|
||||||
|
|
||||||
|
export async function updateDependencies(
|
||||||
|
dependencies: RegistryItem['dependencies'],
|
||||||
|
config: Config,
|
||||||
|
options: {
|
||||||
|
silent?: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
dependencies = Array.from(new Set(dependencies))
|
||||||
|
if (!dependencies?.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options = {
|
||||||
|
silent: false,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
|
||||||
|
const dependenciesSpinner = spinner(`Installing dependencies.`, { silent: options.silent })?.start()
|
||||||
|
dependenciesSpinner?.start()
|
||||||
|
|
||||||
|
await addDependency(dependencies, { cwd: config.resolvedPaths.cwd })
|
||||||
|
dependenciesSpinner?.succeed()
|
||||||
|
}
|
||||||
173
packages/cli/src/utils/updaters/update-files.ts
Normal file
173
packages/cli/src/utils/updaters/update-files.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
import type { Config } from '@/src/utils/get-config'
|
||||||
|
import type { RegistryItem } from '@/src/utils/registry/schema'
|
||||||
|
import { existsSync, promises as fs } from 'node:fs'
|
||||||
|
import path, { basename } from 'node:path'
|
||||||
|
import { getProjectInfo } from '@/src/utils/get-project-info'
|
||||||
|
import { highlighter } from '@/src/utils/highlighter'
|
||||||
|
import { logger } from '@/src/utils/logger'
|
||||||
|
import {
|
||||||
|
getRegistryBaseColor,
|
||||||
|
getRegistryItemFileTargetPath,
|
||||||
|
} from '@/src/utils/registry'
|
||||||
|
import { spinner } from '@/src/utils/spinner'
|
||||||
|
import { transform } from '@/src/utils/transformers'
|
||||||
|
// import { transformIcons } from '@/src/utils/transformers/transform-icons'
|
||||||
|
import prompts from 'prompts'
|
||||||
|
|
||||||
|
export function resolveTargetDir(
|
||||||
|
projectInfo: Awaited<ReturnType<typeof getProjectInfo>>,
|
||||||
|
config: Config,
|
||||||
|
target: string,
|
||||||
|
) {
|
||||||
|
if (target.startsWith('~/')) {
|
||||||
|
return path.join(config.resolvedPaths.cwd, target.replace('~/', ''))
|
||||||
|
}
|
||||||
|
return projectInfo?.isSrcDir
|
||||||
|
? path.join(config.resolvedPaths.cwd, 'src', target)
|
||||||
|
: path.join(config.resolvedPaths.cwd, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFiles(
|
||||||
|
files: RegistryItem['files'],
|
||||||
|
config: Config,
|
||||||
|
options: {
|
||||||
|
overwrite?: boolean
|
||||||
|
force?: boolean
|
||||||
|
silent?: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!files?.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options = {
|
||||||
|
overwrite: false,
|
||||||
|
force: false,
|
||||||
|
silent: false,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
const filesCreatedSpinner = spinner(`Updating files.`, {
|
||||||
|
silent: options.silent,
|
||||||
|
})?.start()
|
||||||
|
|
||||||
|
const [projectInfo, baseColor] = await Promise.all([
|
||||||
|
getProjectInfo(config.resolvedPaths.cwd),
|
||||||
|
getRegistryBaseColor(config.tailwind.baseColor),
|
||||||
|
])
|
||||||
|
|
||||||
|
const filesCreated = []
|
||||||
|
const filesUpdated = []
|
||||||
|
const filesSkipped = []
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.content) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetDir = getRegistryItemFileTargetPath(file, config)
|
||||||
|
const fileName = basename(file.path)
|
||||||
|
let filePath = path.join(targetDir, fileName)
|
||||||
|
|
||||||
|
if (file.target) {
|
||||||
|
filePath = resolveTargetDir(projectInfo, config, file.target)
|
||||||
|
targetDir = path.dirname(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.typescript) {
|
||||||
|
filePath = filePath.replace(/\.ts?$/, match => '.js')
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingFile = existsSync(filePath)
|
||||||
|
if (existingFile && !options.overwrite) {
|
||||||
|
filesCreatedSpinner.stop()
|
||||||
|
const { overwrite } = await prompts({
|
||||||
|
type: 'confirm',
|
||||||
|
name: 'overwrite',
|
||||||
|
message: `The file ${highlighter.info(
|
||||||
|
fileName,
|
||||||
|
)} already exists. Would you like to overwrite?`,
|
||||||
|
initial: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!overwrite) {
|
||||||
|
filesSkipped.push(path.relative(config.resolvedPaths.cwd, filePath))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filesCreatedSpinner?.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the target directory if it doesn't exist.
|
||||||
|
if (!existsSync(targetDir)) {
|
||||||
|
await fs.mkdir(targetDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run our transformers.
|
||||||
|
const content = await transform({
|
||||||
|
filename: file.path,
|
||||||
|
raw: file.content,
|
||||||
|
config,
|
||||||
|
baseColor,
|
||||||
|
})
|
||||||
|
|
||||||
|
await fs.writeFile(filePath, content, 'utf-8')
|
||||||
|
existingFile
|
||||||
|
? filesUpdated.push(path.relative(config.resolvedPaths.cwd, filePath))
|
||||||
|
: filesCreated.push(path.relative(config.resolvedPaths.cwd, filePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasUpdatedFiles = filesCreated.length || filesUpdated.length
|
||||||
|
if (!hasUpdatedFiles && !filesSkipped.length) {
|
||||||
|
filesCreatedSpinner?.info('No files updated.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesCreated.length) {
|
||||||
|
filesCreatedSpinner?.succeed(
|
||||||
|
`Created ${filesCreated.length} ${
|
||||||
|
filesCreated.length === 1 ? 'file' : 'files'
|
||||||
|
}:`,
|
||||||
|
)
|
||||||
|
if (!options.silent) {
|
||||||
|
for (const file of filesCreated) {
|
||||||
|
logger.log(` - ${file}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
filesCreatedSpinner?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesUpdated.length) {
|
||||||
|
spinner(
|
||||||
|
`Updated ${filesUpdated.length} ${
|
||||||
|
filesUpdated.length === 1 ? 'file' : 'files'
|
||||||
|
}:`,
|
||||||
|
{
|
||||||
|
silent: options.silent,
|
||||||
|
},
|
||||||
|
)?.info()
|
||||||
|
if (!options.silent) {
|
||||||
|
for (const file of filesUpdated) {
|
||||||
|
logger.log(` - ${file}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesSkipped.length) {
|
||||||
|
spinner(
|
||||||
|
`Skipped ${filesSkipped.length} ${
|
||||||
|
filesUpdated.length === 1 ? 'file' : 'files'
|
||||||
|
}:`,
|
||||||
|
{
|
||||||
|
silent: options.silent,
|
||||||
|
},
|
||||||
|
)?.info()
|
||||||
|
if (!options.silent) {
|
||||||
|
for (const file of filesSkipped) {
|
||||||
|
logger.log(` - ${file}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.silent) {
|
||||||
|
logger.break()
|
||||||
|
}
|
||||||
|
}
|
||||||
545
packages/cli/src/utils/updaters/update-tailwind-config.ts
Normal file
545
packages/cli/src/utils/updaters/update-tailwind-config.ts
Normal file
|
|
@ -0,0 +1,545 @@
|
||||||
|
import type { Config } from '@/src/utils/get-config'
|
||||||
|
import type { registryItemTailwindSchema } from '@/src/utils/registry/schema'
|
||||||
|
import type { Config as TailwindConfig } from 'tailwindcss'
|
||||||
|
import type {
|
||||||
|
ArrayLiteralExpression,
|
||||||
|
ObjectLiteralExpression,
|
||||||
|
PropertyAssignment,
|
||||||
|
VariableStatement,
|
||||||
|
} from 'ts-morph'
|
||||||
|
import type { z } from 'zod'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { highlighter } from '@/src/utils/highlighter'
|
||||||
|
import { spinner } from '@/src/utils/spinner'
|
||||||
|
import deepmerge from 'deepmerge'
|
||||||
|
import objectToString from 'stringify-object'
|
||||||
|
import {
|
||||||
|
Project,
|
||||||
|
QuoteKind,
|
||||||
|
ScriptKind,
|
||||||
|
SyntaxKind,
|
||||||
|
} from 'ts-morph'
|
||||||
|
|
||||||
|
export type UpdaterTailwindConfig = Omit<TailwindConfig, 'plugins'> & {
|
||||||
|
// We only want string plugins for now.
|
||||||
|
plugins?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTailwindConfig(
|
||||||
|
tailwindConfig:
|
||||||
|
| z.infer<typeof registryItemTailwindSchema>['config']
|
||||||
|
| undefined,
|
||||||
|
config: Config,
|
||||||
|
options: {
|
||||||
|
silent?: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!tailwindConfig) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options = {
|
||||||
|
silent: false,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
|
||||||
|
const tailwindFileRelativePath = path.relative(
|
||||||
|
config.resolvedPaths.cwd,
|
||||||
|
config.resolvedPaths.tailwindConfig,
|
||||||
|
)
|
||||||
|
const tailwindSpinner = spinner(
|
||||||
|
`Updating ${highlighter.info(tailwindFileRelativePath)}`,
|
||||||
|
{
|
||||||
|
silent: options.silent,
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
const raw = await fs.readFile(config.resolvedPaths.tailwindConfig, 'utf8')
|
||||||
|
const output = await transformTailwindConfig(raw, tailwindConfig, config)
|
||||||
|
await fs.writeFile(config.resolvedPaths.tailwindConfig, output, 'utf8')
|
||||||
|
tailwindSpinner?.succeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transformTailwindConfig(
|
||||||
|
input: string,
|
||||||
|
tailwindConfig: UpdaterTailwindConfig,
|
||||||
|
config: Config,
|
||||||
|
) {
|
||||||
|
const sourceFile = await _createSourceFile(input, config)
|
||||||
|
// Find the object with content property.
|
||||||
|
// This is faster than traversing the default export.
|
||||||
|
// TODO: maybe we do need to traverse the default export?
|
||||||
|
const configObject = sourceFile
|
||||||
|
.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)
|
||||||
|
.find(node =>
|
||||||
|
node
|
||||||
|
.getProperties()
|
||||||
|
.some(
|
||||||
|
property =>
|
||||||
|
property.isKind(SyntaxKind.PropertyAssignment)
|
||||||
|
&& property.getName() === 'content',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// We couldn't find the config object, so we return the input as is.
|
||||||
|
if (!configObject) {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoteChar = _getQuoteChar(configObject)
|
||||||
|
|
||||||
|
// Add darkMode.
|
||||||
|
addTailwindConfigProperty(
|
||||||
|
configObject,
|
||||||
|
{
|
||||||
|
name: 'darkMode',
|
||||||
|
value: 'class',
|
||||||
|
},
|
||||||
|
{ quoteChar },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add Tailwind config plugins.
|
||||||
|
tailwindConfig.plugins?.forEach((plugin) => {
|
||||||
|
addTailwindConfigPlugin(configObject, plugin)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add Tailwind config theme.
|
||||||
|
if (tailwindConfig.theme) {
|
||||||
|
await addTailwindConfigTheme(configObject, tailwindConfig.theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sourceFile.getFullText()
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTailwindConfigProperty(
|
||||||
|
configObject: ObjectLiteralExpression,
|
||||||
|
property: {
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quoteChar,
|
||||||
|
}: {
|
||||||
|
quoteChar: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const existingProperty = configObject.getProperty('darkMode')
|
||||||
|
|
||||||
|
if (!existingProperty) {
|
||||||
|
const newProperty = {
|
||||||
|
name: property.name,
|
||||||
|
initializer: `[${quoteChar}${property.value}${quoteChar}]`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to add darkMode as the first property.
|
||||||
|
if (property.name === 'darkMode') {
|
||||||
|
configObject.insertPropertyAssignment(0, newProperty)
|
||||||
|
return configObject
|
||||||
|
}
|
||||||
|
|
||||||
|
configObject.addPropertyAssignment(newProperty)
|
||||||
|
|
||||||
|
return configObject
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingProperty.isKind(SyntaxKind.PropertyAssignment)) {
|
||||||
|
const initializer = existingProperty.getInitializer()
|
||||||
|
const newValue = `${quoteChar}${property.value}${quoteChar}`
|
||||||
|
|
||||||
|
// If property is a string, change it to an array and append.
|
||||||
|
if (initializer?.isKind(SyntaxKind.StringLiteral)) {
|
||||||
|
const initializerText = initializer.getText()
|
||||||
|
initializer.replaceWithText(`[${initializerText}, ${newValue}]`)
|
||||||
|
return configObject
|
||||||
|
}
|
||||||
|
|
||||||
|
// If property is an array, append.
|
||||||
|
if (initializer?.isKind(SyntaxKind.ArrayLiteralExpression)) {
|
||||||
|
// Check if the array already contains the value.
|
||||||
|
if (
|
||||||
|
initializer
|
||||||
|
.getElements()
|
||||||
|
.map(element => element.getText())
|
||||||
|
.includes(newValue)
|
||||||
|
) {
|
||||||
|
return configObject
|
||||||
|
}
|
||||||
|
initializer.addElement(newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configObject
|
||||||
|
}
|
||||||
|
|
||||||
|
return configObject
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTailwindConfigTheme(
|
||||||
|
configObject: ObjectLiteralExpression,
|
||||||
|
theme: UpdaterTailwindConfig['theme'],
|
||||||
|
) {
|
||||||
|
// Ensure there is a theme property.
|
||||||
|
if (!configObject.getProperty('theme')) {
|
||||||
|
configObject.addPropertyAssignment({
|
||||||
|
name: 'theme',
|
||||||
|
initializer: '{}',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nest all spread properties.
|
||||||
|
nestSpreadProperties(configObject)
|
||||||
|
|
||||||
|
const themeProperty = configObject
|
||||||
|
.getPropertyOrThrow('theme')
|
||||||
|
?.asKindOrThrow(SyntaxKind.PropertyAssignment)
|
||||||
|
|
||||||
|
const themeInitializer = themeProperty.getInitializer()
|
||||||
|
if (themeInitializer?.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
||||||
|
const themeObjectString = themeInitializer.getText()
|
||||||
|
const themeObject = await parseObjectLiteral(themeObjectString)
|
||||||
|
const result = deepmerge(themeObject, theme, {
|
||||||
|
arrayMerge: (dst, src) => src,
|
||||||
|
})
|
||||||
|
const resultString = objectToString(result)
|
||||||
|
.replace(/'\.\.\.(.*)'/g, '...$1') // Remove quote around spread element
|
||||||
|
.replace(/'"/g, '\'') // Replace `\" with "
|
||||||
|
.replace(/"'/g, '\'') // Replace `\" with "
|
||||||
|
.replace(/'\[/g, '[') // Replace `[ with [
|
||||||
|
.replace(/\]'/g, ']') // Replace `] with ]
|
||||||
|
.replace(/'\\'/g, '\'') // Replace `\' with '
|
||||||
|
.replace(/\\'/g, '\'') // Replace \' with '
|
||||||
|
.replace(/\\''/g, '\'')
|
||||||
|
.replace(/''/g, '\'')
|
||||||
|
themeInitializer.replaceWithText(resultString)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unnest all spread properties.
|
||||||
|
unnestSpreadProperties(configObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTailwindConfigPlugin(
|
||||||
|
configObject: ObjectLiteralExpression,
|
||||||
|
plugin: string,
|
||||||
|
) {
|
||||||
|
const existingPlugins = configObject.getProperty('plugins')
|
||||||
|
|
||||||
|
if (!existingPlugins) {
|
||||||
|
configObject.addPropertyAssignment({
|
||||||
|
name: 'plugins',
|
||||||
|
initializer: `[${plugin}]`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return configObject
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingPlugins.isKind(SyntaxKind.PropertyAssignment)) {
|
||||||
|
const initializer = existingPlugins.getInitializer()
|
||||||
|
|
||||||
|
if (initializer?.isKind(SyntaxKind.ArrayLiteralExpression)) {
|
||||||
|
if (
|
||||||
|
initializer
|
||||||
|
.getElements()
|
||||||
|
.map((element) => {
|
||||||
|
return element.getText().replace(/["']/g, '')
|
||||||
|
})
|
||||||
|
.includes(plugin.replace(/["']/g, ''))
|
||||||
|
) {
|
||||||
|
return configObject
|
||||||
|
}
|
||||||
|
initializer.addElement(plugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configObject
|
||||||
|
}
|
||||||
|
|
||||||
|
return configObject
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function _createSourceFile(input: string, config: Config | null) {
|
||||||
|
const dir = await fs.mkdtemp(path.join(tmpdir(), 'shadcn-'))
|
||||||
|
const resolvedPath
|
||||||
|
= config?.resolvedPaths?.tailwindConfig || 'tailwind.config.ts'
|
||||||
|
const tempFile = path.join(dir, `shadcn-${path.basename(resolvedPath)}`)
|
||||||
|
|
||||||
|
const project = new Project({
|
||||||
|
compilerOptions: {},
|
||||||
|
})
|
||||||
|
const sourceFile = project.createSourceFile(tempFile, input, {
|
||||||
|
// Note: .js and .mjs can still be valid for TS projects.
|
||||||
|
// We can't infer TypeScript from config.tsx.
|
||||||
|
scriptKind:
|
||||||
|
path.extname(resolvedPath) === '.ts' ? ScriptKind.TS : ScriptKind.JS,
|
||||||
|
})
|
||||||
|
|
||||||
|
return sourceFile
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _getQuoteChar(configObject: ObjectLiteralExpression) {
|
||||||
|
return configObject
|
||||||
|
.getFirstDescendantByKind(SyntaxKind.StringLiteral)
|
||||||
|
?.getQuoteKind() === QuoteKind.Single
|
||||||
|
? '\''
|
||||||
|
: '"'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nestSpreadProperties(obj: ObjectLiteralExpression) {
|
||||||
|
const properties = obj.getProperties()
|
||||||
|
|
||||||
|
for (let i = 0; i < properties.length; i++) {
|
||||||
|
const prop = properties[i]
|
||||||
|
if (prop.isKind(SyntaxKind.SpreadAssignment)) {
|
||||||
|
const spreadAssignment = prop.asKindOrThrow(SyntaxKind.SpreadAssignment)
|
||||||
|
const spreadText = spreadAssignment.getExpression().getText()
|
||||||
|
|
||||||
|
// Replace spread with a property assignment
|
||||||
|
obj.insertPropertyAssignment(i, {
|
||||||
|
// Need to escape the name with " so that deepmerge doesn't mishandle the key
|
||||||
|
name: `"___${spreadText.replace(/^\.\.\./, '')}"`,
|
||||||
|
initializer: `"...${spreadText.replace(/^\.\.\./, '')}"`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove the original spread assignment
|
||||||
|
spreadAssignment.remove()
|
||||||
|
}
|
||||||
|
else if (prop.isKind(SyntaxKind.PropertyAssignment)) {
|
||||||
|
const propAssignment = prop.asKindOrThrow(SyntaxKind.PropertyAssignment)
|
||||||
|
const initializer = propAssignment.getInitializer()
|
||||||
|
|
||||||
|
if (
|
||||||
|
initializer
|
||||||
|
&& initializer.isKind(SyntaxKind.ObjectLiteralExpression)
|
||||||
|
) {
|
||||||
|
// Recursively process nested object literals
|
||||||
|
nestSpreadProperties(
|
||||||
|
initializer.asKindOrThrow(SyntaxKind.ObjectLiteralExpression),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else if (
|
||||||
|
initializer
|
||||||
|
&& initializer.isKind(SyntaxKind.ArrayLiteralExpression)
|
||||||
|
) {
|
||||||
|
nestSpreadElements(
|
||||||
|
initializer.asKindOrThrow(SyntaxKind.ArrayLiteralExpression),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nestSpreadElements(arr: ArrayLiteralExpression) {
|
||||||
|
const elements = arr.getElements()
|
||||||
|
for (let j = 0; j < elements.length; j++) {
|
||||||
|
const element = elements[j]
|
||||||
|
if (element.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
||||||
|
// Recursive check on objects within arrays
|
||||||
|
nestSpreadProperties(
|
||||||
|
element.asKindOrThrow(SyntaxKind.ObjectLiteralExpression),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else if (element.isKind(SyntaxKind.ArrayLiteralExpression)) {
|
||||||
|
// Recursive check on nested arrays
|
||||||
|
nestSpreadElements(
|
||||||
|
element.asKindOrThrow(SyntaxKind.ArrayLiteralExpression),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else if (element.isKind(SyntaxKind.SpreadElement)) {
|
||||||
|
const spreadText = element.getText()
|
||||||
|
// Spread element within an array
|
||||||
|
arr.removeElement(j)
|
||||||
|
arr.insertElement(j, `"${spreadText}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unnestSpreadProperties(obj: ObjectLiteralExpression) {
|
||||||
|
const properties = obj.getProperties()
|
||||||
|
|
||||||
|
for (let i = 0; i < properties.length; i++) {
|
||||||
|
const prop = properties[i]
|
||||||
|
if (prop.isKind(SyntaxKind.PropertyAssignment)) {
|
||||||
|
const propAssignment = prop as PropertyAssignment
|
||||||
|
const initializer = propAssignment.getInitializer()
|
||||||
|
|
||||||
|
if (initializer && initializer.isKind(SyntaxKind.StringLiteral)) {
|
||||||
|
const value = initializer
|
||||||
|
.asKindOrThrow(SyntaxKind.StringLiteral)
|
||||||
|
.getLiteralValue()
|
||||||
|
if (value.startsWith('...')) {
|
||||||
|
obj.insertSpreadAssignment(i, { expression: value.slice(3) })
|
||||||
|
propAssignment.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
||||||
|
unnestSpreadProperties(initializer as ObjectLiteralExpression)
|
||||||
|
}
|
||||||
|
else if (
|
||||||
|
initializer
|
||||||
|
&& initializer.isKind(SyntaxKind.ArrayLiteralExpression)
|
||||||
|
) {
|
||||||
|
unnsetSpreadElements(
|
||||||
|
initializer.asKindOrThrow(SyntaxKind.ArrayLiteralExpression),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unnsetSpreadElements(arr: ArrayLiteralExpression) {
|
||||||
|
const elements = arr.getElements()
|
||||||
|
for (let j = 0; j < elements.length; j++) {
|
||||||
|
const element = elements[j]
|
||||||
|
if (element.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
||||||
|
// Recursive check on objects within arrays
|
||||||
|
unnestSpreadProperties(
|
||||||
|
element.asKindOrThrow(SyntaxKind.ObjectLiteralExpression),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else if (element.isKind(SyntaxKind.ArrayLiteralExpression)) {
|
||||||
|
// Recursive check on nested arrays
|
||||||
|
unnsetSpreadElements(
|
||||||
|
element.asKindOrThrow(SyntaxKind.ArrayLiteralExpression),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else if (element.isKind(SyntaxKind.StringLiteral)) {
|
||||||
|
const spreadText = element.getText()
|
||||||
|
// check if spread element
|
||||||
|
const spreadTest = /^['"](\.\.\..*)['"]$/g
|
||||||
|
if (spreadTest.test(spreadText)) {
|
||||||
|
arr.removeElement(j)
|
||||||
|
arr.insertElement(j, spreadText.replace(spreadTest, '$1'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseObjectLiteral(objectLiteralString: string): Promise<any> {
|
||||||
|
const sourceFile = await _createSourceFile(
|
||||||
|
`const theme = ${objectLiteralString}`,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const statement = sourceFile.getStatements()[0]
|
||||||
|
if (statement?.getKind() === SyntaxKind.VariableStatement) {
|
||||||
|
const declaration = (statement as VariableStatement)
|
||||||
|
.getDeclarationList()
|
||||||
|
?.getDeclarations()[0]
|
||||||
|
const initializer = declaration.getInitializer()
|
||||||
|
if (initializer?.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
||||||
|
return await parseObjectLiteralExpression(initializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid input: not an object literal')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObjectLiteralExpression(node: ObjectLiteralExpression): any {
|
||||||
|
const result: any = {}
|
||||||
|
for (const property of node.getProperties()) {
|
||||||
|
if (property.isKind(SyntaxKind.PropertyAssignment)) {
|
||||||
|
const name = property.getName().replace(/'/g, '')
|
||||||
|
if (
|
||||||
|
property.getInitializer()?.isKind(SyntaxKind.ObjectLiteralExpression)
|
||||||
|
) {
|
||||||
|
result[name] = parseObjectLiteralExpression(
|
||||||
|
property.getInitializer() as ObjectLiteralExpression,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else if (
|
||||||
|
property.getInitializer()?.isKind(SyntaxKind.ArrayLiteralExpression)
|
||||||
|
) {
|
||||||
|
result[name] = parseArrayLiteralExpression(
|
||||||
|
property.getInitializer() as ArrayLiteralExpression,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
result[name] = parseValue(property.getInitializer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArrayLiteralExpression(node: ArrayLiteralExpression): any[] {
|
||||||
|
const result: any[] = []
|
||||||
|
for (const element of node.getElements()) {
|
||||||
|
if (element.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
||||||
|
result.push(
|
||||||
|
parseObjectLiteralExpression(
|
||||||
|
element.asKindOrThrow(SyntaxKind.ObjectLiteralExpression),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else if (element.isKind(SyntaxKind.ArrayLiteralExpression)) {
|
||||||
|
result.push(
|
||||||
|
parseArrayLiteralExpression(
|
||||||
|
element.asKindOrThrow(SyntaxKind.ArrayLiteralExpression),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
result.push(parseValue(element))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseValue(node: any): any {
|
||||||
|
switch (node.getKind()) {
|
||||||
|
case SyntaxKind.StringLiteral:
|
||||||
|
return node.getText()
|
||||||
|
case SyntaxKind.NumericLiteral:
|
||||||
|
return Number(node.getText())
|
||||||
|
case SyntaxKind.TrueKeyword:
|
||||||
|
return true
|
||||||
|
case SyntaxKind.FalseKeyword:
|
||||||
|
return false
|
||||||
|
case SyntaxKind.NullKeyword:
|
||||||
|
return null
|
||||||
|
case SyntaxKind.ArrayLiteralExpression:
|
||||||
|
return node.getElements().map(parseValue)
|
||||||
|
case SyntaxKind.ObjectLiteralExpression:
|
||||||
|
return parseObjectLiteralExpression(node)
|
||||||
|
default:
|
||||||
|
return node.getText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTailwindThemeColorsFromCssVars(
|
||||||
|
cssVars: Record<string, string>,
|
||||||
|
) {
|
||||||
|
const result: Record<string, any> = {}
|
||||||
|
|
||||||
|
for (const key of Object.keys(cssVars)) {
|
||||||
|
const parts = key.split('-')
|
||||||
|
const colorName = parts[0]
|
||||||
|
const subType = parts.slice(1).join('-')
|
||||||
|
|
||||||
|
if (subType === '') {
|
||||||
|
if (typeof result[colorName] === 'object') {
|
||||||
|
result[colorName].DEFAULT = `hsl(var(--${key}))`
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
result[colorName] = `hsl(var(--${key}))`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (typeof result[colorName] !== 'object') {
|
||||||
|
result[colorName] = { DEFAULT: `hsl(var(--${colorName}))` }
|
||||||
|
}
|
||||||
|
result[colorName][subType] = `hsl(var(--${key}))`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove DEFAULT if it's not in the original cssVars
|
||||||
|
for (const [colorName, value] of Object.entries(result)) {
|
||||||
|
if (
|
||||||
|
typeof value === 'object'
|
||||||
|
&& value.DEFAULT === `hsl(var(--${colorName}))`
|
||||||
|
&& !(colorName in cssVars)
|
||||||
|
) {
|
||||||
|
delete value.DEFAULT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
122
packages/cli/src/utils/updaters/update-tailwind-content.ts
Normal file
122
packages/cli/src/utils/updaters/update-tailwind-content.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import type { Config } from '@/src/utils/get-config'
|
||||||
|
import type { ObjectLiteralExpression } from 'ts-morph'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { highlighter } from '@/src/utils/highlighter'
|
||||||
|
import { spinner } from '@/src/utils/spinner'
|
||||||
|
import {
|
||||||
|
_createSourceFile,
|
||||||
|
_getQuoteChar,
|
||||||
|
} from '@/src/utils/updaters/update-tailwind-config'
|
||||||
|
import { SyntaxKind } from 'ts-morph'
|
||||||
|
|
||||||
|
export async function updateTailwindContent(
|
||||||
|
content: string[],
|
||||||
|
config: Config,
|
||||||
|
options: {
|
||||||
|
silent?: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!content) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options = {
|
||||||
|
silent: false,
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
|
||||||
|
const tailwindFileRelativePath = path.relative(
|
||||||
|
config.resolvedPaths.cwd,
|
||||||
|
config.resolvedPaths.tailwindConfig,
|
||||||
|
)
|
||||||
|
const tailwindSpinner = spinner(
|
||||||
|
`Updating ${highlighter.info(tailwindFileRelativePath)}`,
|
||||||
|
{
|
||||||
|
silent: options.silent,
|
||||||
|
},
|
||||||
|
).start()
|
||||||
|
const raw = await fs.readFile(config.resolvedPaths.tailwindConfig, 'utf8')
|
||||||
|
const output = await transformTailwindContent(raw, content, config)
|
||||||
|
await fs.writeFile(config.resolvedPaths.tailwindConfig, output, 'utf8')
|
||||||
|
tailwindSpinner?.succeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transformTailwindContent(
|
||||||
|
input: string,
|
||||||
|
content: string[],
|
||||||
|
config: Config,
|
||||||
|
) {
|
||||||
|
const sourceFile = await _createSourceFile(input, config)
|
||||||
|
// Find the object with content property.
|
||||||
|
// This is faster than traversing the default export.
|
||||||
|
// TODO: maybe we do need to traverse the default export?
|
||||||
|
const configObject = sourceFile
|
||||||
|
.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)
|
||||||
|
.find(node =>
|
||||||
|
node
|
||||||
|
.getProperties()
|
||||||
|
.some(
|
||||||
|
property =>
|
||||||
|
property.isKind(SyntaxKind.PropertyAssignment)
|
||||||
|
&& property.getName() === 'content',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// We couldn't find the config object, so we return the input as is.
|
||||||
|
if (!configObject) {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
addTailwindConfigContent(configObject, content)
|
||||||
|
|
||||||
|
return sourceFile.getFullText()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTailwindConfigContent(
|
||||||
|
configObject: ObjectLiteralExpression,
|
||||||
|
content: string[],
|
||||||
|
) {
|
||||||
|
const quoteChar = _getQuoteChar(configObject)
|
||||||
|
|
||||||
|
const existingProperty = configObject.getProperty('content')
|
||||||
|
|
||||||
|
if (!existingProperty) {
|
||||||
|
const newProperty = {
|
||||||
|
name: 'content',
|
||||||
|
initializer: `[${quoteChar}${content.join(
|
||||||
|
`${quoteChar}, ${quoteChar}`,
|
||||||
|
)}${quoteChar}]`,
|
||||||
|
}
|
||||||
|
configObject.addPropertyAssignment(newProperty)
|
||||||
|
|
||||||
|
return configObject
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingProperty.isKind(SyntaxKind.PropertyAssignment)) {
|
||||||
|
const initializer = existingProperty.getInitializer()
|
||||||
|
|
||||||
|
// If property is an array, append.
|
||||||
|
if (initializer?.isKind(SyntaxKind.ArrayLiteralExpression)) {
|
||||||
|
for (const contentItem of content) {
|
||||||
|
const newValue = `${quoteChar}${contentItem}${quoteChar}`
|
||||||
|
|
||||||
|
// Check if the array already contains the value.
|
||||||
|
if (
|
||||||
|
initializer
|
||||||
|
.getElements()
|
||||||
|
.map(element => element.getText())
|
||||||
|
.includes(newValue)
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
initializer.addElement(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configObject
|
||||||
|
}
|
||||||
|
|
||||||
|
return configObject
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
},
|
},
|
||||||
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": false
|
"isolatedModules": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"],
|
"include": ["src/**/*.ts"],
|
||||||
|
|
|
||||||
|
|
@ -264,15 +264,24 @@ importers:
|
||||||
consola:
|
consola:
|
||||||
specifier: ^3.2.3
|
specifier: ^3.2.3
|
||||||
version: 3.2.3
|
version: 3.2.3
|
||||||
|
deepmerge:
|
||||||
|
specifier: ^4.3.1
|
||||||
|
version: 4.3.1
|
||||||
diff:
|
diff:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.0
|
version: 7.0.0
|
||||||
|
fast-glob:
|
||||||
|
specifier: ^3.3.2
|
||||||
|
version: 3.3.2
|
||||||
fs-extra:
|
fs-extra:
|
||||||
specifier: ^11.2.0
|
specifier: ^11.2.0
|
||||||
version: 11.2.0
|
version: 11.2.0
|
||||||
https-proxy-agent:
|
https-proxy-agent:
|
||||||
specifier: ^7.0.5
|
specifier: ^7.0.5
|
||||||
version: 7.0.5(supports-color@9.4.0)
|
version: 7.0.5(supports-color@9.4.0)
|
||||||
|
kleur:
|
||||||
|
specifier: ^4.1.5
|
||||||
|
version: 4.1.5
|
||||||
lodash-es:
|
lodash-es:
|
||||||
specifier: ^4.17.21
|
specifier: ^4.17.21
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
|
|
@ -294,6 +303,9 @@ importers:
|
||||||
pkg-types:
|
pkg-types:
|
||||||
specifier: ^1.2.1
|
specifier: ^1.2.1
|
||||||
version: 1.2.1
|
version: 1.2.1
|
||||||
|
postcss:
|
||||||
|
specifier: ^8.4.49
|
||||||
|
version: 8.4.49
|
||||||
prompts:
|
prompts:
|
||||||
specifier: ^2.4.2
|
specifier: ^2.4.2
|
||||||
version: 2.4.2
|
version: 2.4.2
|
||||||
|
|
@ -303,6 +315,15 @@ importers:
|
||||||
semver:
|
semver:
|
||||||
specifier: ^7.6.3
|
specifier: ^7.6.3
|
||||||
version: 7.6.3
|
version: 7.6.3
|
||||||
|
stringify-object:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0
|
||||||
|
tailwindcss:
|
||||||
|
specifier: ^3.4.15
|
||||||
|
version: 3.4.15
|
||||||
|
ts-morph:
|
||||||
|
specifier: ^24.0.0
|
||||||
|
version: 24.0.0
|
||||||
tsconfig-paths:
|
tsconfig-paths:
|
||||||
specifier: ^4.2.0
|
specifier: ^4.2.0
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
|
|
@ -331,6 +352,9 @@ importers:
|
||||||
'@types/prompts':
|
'@types/prompts':
|
||||||
specifier: ^2.4.9
|
specifier: ^2.4.9
|
||||||
version: 2.4.9
|
version: 2.4.9
|
||||||
|
'@types/stringify-object':
|
||||||
|
specifier: ^4.0.5
|
||||||
|
version: 4.0.5
|
||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.3.5
|
specifier: ^8.3.5
|
||||||
version: 8.3.5(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.0)
|
version: 8.3.5(jiti@2.4.0)(postcss@8.4.49)(tsx@4.19.2)(typescript@5.6.3)(yaml@2.6.0)
|
||||||
|
|
@ -2100,6 +2124,9 @@ packages:
|
||||||
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
|
resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
'@ts-morph/common@0.25.0':
|
||||||
|
resolution: {integrity: sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==}
|
||||||
|
|
||||||
'@types/conventional-commits-parser@5.0.0':
|
'@types/conventional-commits-parser@5.0.0':
|
||||||
resolution: {integrity: sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==}
|
resolution: {integrity: sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==}
|
||||||
|
|
||||||
|
|
@ -2298,6 +2325,9 @@ packages:
|
||||||
'@types/responselike@1.0.3':
|
'@types/responselike@1.0.3':
|
||||||
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
|
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
|
||||||
|
|
||||||
|
'@types/stringify-object@4.0.5':
|
||||||
|
resolution: {integrity: sha512-TzX5V+njkbJ8iJ0mrj+Vqveep/1JBH4SSA3J2wYrE1eUrOhdsjTBCb0kao4EquSQ8KgPpqY4zSVP2vCPWKBElg==}
|
||||||
|
|
||||||
'@types/supercluster@5.0.3':
|
'@types/supercluster@5.0.3':
|
||||||
resolution: {integrity: sha512-XMSqQEr7YDuNtFwSgaHHOjsbi0ZGL62V9Js4CW45RBuRYlNWSW/KDqN+RFFE7HdHcGhJPtN0klKvw06r9Kg7rg==}
|
resolution: {integrity: sha512-XMSqQEr7YDuNtFwSgaHHOjsbi0ZGL62V9Js4CW45RBuRYlNWSW/KDqN+RFFE7HdHcGhJPtN0klKvw06r9Kg7rg==}
|
||||||
|
|
||||||
|
|
@ -3142,6 +3172,9 @@ packages:
|
||||||
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
|
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
|
||||||
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
|
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
|
||||||
|
|
||||||
|
code-block-writer@13.0.3:
|
||||||
|
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
|
||||||
|
|
||||||
codesandbox-import-util-types@2.2.3:
|
codesandbox-import-util-types@2.2.3:
|
||||||
resolution: {integrity: sha512-Qj00p60oNExthP2oR3vvXmUGjukij+rxJGuiaKM6tyUmSyimdZsqHI/TUvFFClAffk9s7hxGnQgWQ8KCce27qQ==}
|
resolution: {integrity: sha512-Qj00p60oNExthP2oR3vvXmUGjukij+rxJGuiaKM6tyUmSyimdZsqHI/TUvFFClAffk9s7hxGnQgWQ8KCce27qQ==}
|
||||||
|
|
||||||
|
|
@ -4374,6 +4407,10 @@ packages:
|
||||||
resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==}
|
resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
get-own-enumerable-keys@1.0.0:
|
||||||
|
resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==}
|
||||||
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
get-port-please@3.1.2:
|
get-port-please@3.1.2:
|
||||||
resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==}
|
resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==}
|
||||||
|
|
||||||
|
|
@ -4824,6 +4861,10 @@ packages:
|
||||||
resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==}
|
resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
is-obj@3.0.0:
|
||||||
|
resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
is-path-inside@1.0.1:
|
is-path-inside@1.0.1:
|
||||||
resolution: {integrity: sha512-qhsCR/Esx4U4hg/9I19OVUAJkGWtjRYHMRgUMZE2TDdj+Ag+kttZanLupfddNyglzz50cUlmWzUaI37GDfNx/g==}
|
resolution: {integrity: sha512-qhsCR/Esx4U4hg/9I19OVUAJkGWtjRYHMRgUMZE2TDdj+Ag+kttZanLupfddNyglzz50cUlmWzUaI37GDfNx/g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -4839,6 +4880,10 @@ packages:
|
||||||
is-reference@1.2.1:
|
is-reference@1.2.1:
|
||||||
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
|
resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==}
|
||||||
|
|
||||||
|
is-regexp@3.1.0:
|
||||||
|
resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
is-retry-allowed@1.2.0:
|
is-retry-allowed@1.2.0:
|
||||||
resolution: {integrity: sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==}
|
resolution: {integrity: sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
@ -5009,6 +5054,10 @@ packages:
|
||||||
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
kleur@4.1.5:
|
||||||
|
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
klona@2.0.6:
|
klona@2.0.6:
|
||||||
resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==}
|
resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -6817,6 +6866,10 @@ packages:
|
||||||
stringify-entities@4.0.4:
|
stringify-entities@4.0.4:
|
||||||
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
|
||||||
|
|
||||||
|
stringify-object@5.0.0:
|
||||||
|
resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==}
|
||||||
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
strip-ansi@4.0.0:
|
strip-ansi@4.0.0:
|
||||||
resolution: {integrity: sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==}
|
resolution: {integrity: sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
@ -7097,6 +7150,9 @@ packages:
|
||||||
ts-interface-checker@0.1.13:
|
ts-interface-checker@0.1.13:
|
||||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||||
|
|
||||||
|
ts-morph@24.0.0:
|
||||||
|
resolution: {integrity: sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==}
|
||||||
|
|
||||||
tsconfck@3.1.4:
|
tsconfck@3.1.4:
|
||||||
resolution: {integrity: sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==}
|
resolution: {integrity: sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==}
|
||||||
engines: {node: ^18 || >=20}
|
engines: {node: ^18 || >=20}
|
||||||
|
|
@ -9652,6 +9708,12 @@ snapshots:
|
||||||
|
|
||||||
'@trysound/sax@0.2.0': {}
|
'@trysound/sax@0.2.0': {}
|
||||||
|
|
||||||
|
'@ts-morph/common@0.25.0':
|
||||||
|
dependencies:
|
||||||
|
minimatch: 9.0.5
|
||||||
|
path-browserify: 1.0.1
|
||||||
|
tinyglobby: 0.2.10
|
||||||
|
|
||||||
'@types/conventional-commits-parser@5.0.0':
|
'@types/conventional-commits-parser@5.0.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.9.0
|
'@types/node': 22.9.0
|
||||||
|
|
@ -9880,6 +9942,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.9.0
|
'@types/node': 22.9.0
|
||||||
|
|
||||||
|
'@types/stringify-object@4.0.5': {}
|
||||||
|
|
||||||
'@types/supercluster@5.0.3':
|
'@types/supercluster@5.0.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/geojson': 7946.0.14
|
'@types/geojson': 7946.0.14
|
||||||
|
|
@ -10979,6 +11043,8 @@ snapshots:
|
||||||
|
|
||||||
co@4.6.0: {}
|
co@4.6.0: {}
|
||||||
|
|
||||||
|
code-block-writer@13.0.3: {}
|
||||||
|
|
||||||
codesandbox-import-util-types@2.2.3: {}
|
codesandbox-import-util-types@2.2.3: {}
|
||||||
|
|
||||||
codesandbox-import-utils@2.2.3:
|
codesandbox-import-utils@2.2.3:
|
||||||
|
|
@ -12379,6 +12445,8 @@ snapshots:
|
||||||
|
|
||||||
get-east-asian-width@1.3.0: {}
|
get-east-asian-width@1.3.0: {}
|
||||||
|
|
||||||
|
get-own-enumerable-keys@1.0.0: {}
|
||||||
|
|
||||||
get-port-please@3.1.2: {}
|
get-port-please@3.1.2: {}
|
||||||
|
|
||||||
get-stream@3.0.0: {}
|
get-stream@3.0.0: {}
|
||||||
|
|
@ -12875,6 +12943,8 @@ snapshots:
|
||||||
|
|
||||||
is-obj@2.0.0: {}
|
is-obj@2.0.0: {}
|
||||||
|
|
||||||
|
is-obj@3.0.0: {}
|
||||||
|
|
||||||
is-path-inside@1.0.1:
|
is-path-inside@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-is-inside: 1.0.2
|
path-is-inside: 1.0.2
|
||||||
|
|
@ -12887,6 +12957,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.6
|
'@types/estree': 1.0.6
|
||||||
|
|
||||||
|
is-regexp@3.1.0: {}
|
||||||
|
|
||||||
is-retry-allowed@1.2.0: {}
|
is-retry-allowed@1.2.0: {}
|
||||||
|
|
||||||
is-ssh@1.4.0:
|
is-ssh@1.4.0:
|
||||||
|
|
@ -13016,6 +13088,8 @@ snapshots:
|
||||||
|
|
||||||
kleur@3.0.3: {}
|
kleur@3.0.3: {}
|
||||||
|
|
||||||
|
kleur@4.1.5: {}
|
||||||
|
|
||||||
klona@2.0.6: {}
|
klona@2.0.6: {}
|
||||||
|
|
||||||
knitwork@1.1.0: {}
|
knitwork@1.1.0: {}
|
||||||
|
|
@ -15301,6 +15375,12 @@ snapshots:
|
||||||
character-entities-html4: 2.1.0
|
character-entities-html4: 2.1.0
|
||||||
character-entities-legacy: 3.0.0
|
character-entities-legacy: 3.0.0
|
||||||
|
|
||||||
|
stringify-object@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
get-own-enumerable-keys: 1.0.0
|
||||||
|
is-obj: 3.0.0
|
||||||
|
is-regexp: 3.1.0
|
||||||
|
|
||||||
strip-ansi@4.0.0:
|
strip-ansi@4.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex: 3.0.1
|
ansi-regex: 3.0.1
|
||||||
|
|
@ -15611,6 +15691,11 @@ snapshots:
|
||||||
|
|
||||||
ts-interface-checker@0.1.13: {}
|
ts-interface-checker@0.1.13: {}
|
||||||
|
|
||||||
|
ts-morph@24.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@ts-morph/common': 0.25.0
|
||||||
|
code-block-writer: 13.0.3
|
||||||
|
|
||||||
tsconfck@3.1.4(typescript@5.6.3):
|
tsconfck@3.1.4(typescript@5.6.3):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.6.3
|
typescript: 5.6.3
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user