From 8cd51af246440cb7a0ac34327bbb9ec8db912462 Mon Sep 17 00:00:00 2001 From: zernonia Date: Wed, 20 Nov 2024 18:07:34 +0800 Subject: [PATCH] refactor: new CLI --- packages/cli/package.json | 8 + packages/cli/src/commands/add.ts | 313 +++++----- packages/cli/src/commands/diff.ts | 100 ++-- packages/cli/src/commands/info.ts | 21 + packages/cli/src/commands/init.ts | 415 ++++++------- packages/cli/src/commands/migrate.ts | 89 +++ packages/cli/src/index.ts | 19 +- .../cli/src/migrations/migrate-icons.test.ts | 158 +++++ packages/cli/src/migrations/migrate-icons.ts | 201 +++++++ packages/cli/src/preflights/preflight-add.ts | 62 ++ packages/cli/src/preflights/preflight-init.ts | 146 +++++ .../cli/src/preflights/preflight-migrate.ts | 65 +++ packages/cli/src/utils/add-components.ts | 56 ++ packages/cli/src/utils/create-project.ts | 118 ++++ packages/cli/src/utils/errors.ts | 12 + packages/cli/src/utils/frameworks.ts | 36 ++ packages/cli/src/utils/get-config.ts | 100 ++-- packages/cli/src/utils/get-package-info.ts | 20 +- packages/cli/src/utils/get-project-info.ts | 291 ++++++++-- packages/cli/src/utils/highlighter.ts | 8 + packages/cli/src/utils/icon-libraries.ts | 12 + packages/cli/src/utils/logger.ts | 23 + packages/cli/src/utils/registry/index.ts | 388 +++++++++++-- packages/cli/src/utils/registry/schema.ts | 74 ++- packages/cli/src/utils/spinner.ts | 13 + .../cli/src/utils/updaters/update-css-vars.ts | 298 ++++++++++ .../src/utils/updaters/update-dependencies.ts | 28 + .../cli/src/utils/updaters/update-files.ts | 173 ++++++ .../utils/updaters/update-tailwind-config.ts | 545 ++++++++++++++++++ .../utils/updaters/update-tailwind-content.ts | 122 ++++ packages/cli/tsconfig.json | 1 + pnpm-lock.yaml | 85 +++ 32 files changed, 3424 insertions(+), 576 deletions(-) create mode 100644 packages/cli/src/commands/info.ts create mode 100644 packages/cli/src/commands/migrate.ts create mode 100644 packages/cli/src/migrations/migrate-icons.test.ts create mode 100644 packages/cli/src/migrations/migrate-icons.ts create mode 100644 packages/cli/src/preflights/preflight-add.ts create mode 100644 packages/cli/src/preflights/preflight-init.ts create mode 100644 packages/cli/src/preflights/preflight-migrate.ts create mode 100644 packages/cli/src/utils/add-components.ts create mode 100644 packages/cli/src/utils/create-project.ts create mode 100644 packages/cli/src/utils/errors.ts create mode 100644 packages/cli/src/utils/frameworks.ts create mode 100644 packages/cli/src/utils/highlighter.ts create mode 100644 packages/cli/src/utils/icon-libraries.ts create mode 100644 packages/cli/src/utils/logger.ts create mode 100644 packages/cli/src/utils/spinner.ts create mode 100644 packages/cli/src/utils/updaters/update-css-vars.ts create mode 100644 packages/cli/src/utils/updaters/update-dependencies.ts create mode 100644 packages/cli/src/utils/updaters/update-files.ts create mode 100644 packages/cli/src/utils/updaters/update-tailwind-config.ts create mode 100644 packages/cli/src/utils/updaters/update-tailwind-content.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 8e5c16dc..2f3b432e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -56,9 +56,12 @@ "c12": "^2.0.1", "commander": "^12.1.0", "consola": "^3.2.3", + "deepmerge": "^4.3.1", "diff": "^7.0.0", + "fast-glob": "^3.3.2", "fs-extra": "^11.2.0", "https-proxy-agent": "^7.0.5", + "kleur": "^4.1.5", "lodash-es": "^4.17.21", "magic-string": "^0.30.13", "nypm": "^0.3.12", @@ -66,9 +69,13 @@ "ora": "^8.1.1", "pathe": "^1.1.2", "pkg-types": "^1.2.1", + "postcss": "^8.4.49", "prompts": "^2.4.2", "reka-ui": "catalog:", "semver": "^7.6.3", + "stringify-object": "^5.0.0", + "tailwindcss": "^3.4.15", + "ts-morph": "^24.0.0", "tsconfig-paths": "^4.2.0", "vue-metamorph": "3.2.0", "zod": "^3.23.8" @@ -79,6 +86,7 @@ "@types/lodash-es": "^4.17.12", "@types/node": "^22.9.0", "@types/prompts": "^2.4.9", + "@types/stringify-object": "^4.0.5", "tsup": "^8.3.5", "type-fest": "^4.27.0", "typescript": "catalog:", diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index c0944827..a9f89713 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -1,38 +1,35 @@ -import { existsSync, promises as fs, rmSync } from 'node:fs' -import process from 'node:process' -import { getConfig } from '@/src/utils/get-config' +import path from 'node:path' +import { runInit } from '@/src/commands/init' +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 { - fetchTree, - getItemTargetPath, - getRegistryBaseColor, - getRegistryIndex, - resolveTree, -} from '@/src/utils/registry' -import { transform } from '@/src/utils/transformers' +import { highlighter } from '@/src/utils/highlighter' +import { logger } from '@/src/utils/logger' +import { getRegistryIndex } from '@/src/utils/registry' 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 { z } from 'zod' -const addOptionsSchema = z.object({ +export const addOptionsSchema = z.object({ components: z.array(z.string()).optional(), yes: z.boolean(), overwrite: z.boolean(), cwd: z.string(), all: z.boolean(), path: z.string().optional(), + silent: z.boolean(), + srcDir: z.boolean().optional(), }) export const add = new Command() .name('add') .description('add a component to your project') - .argument('[components...]', 'the components to add') - .option('-y, --yes', 'skip confirmation prompt.', true) + .argument( + '[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( '-c, --cwd ', @@ -41,182 +38,158 @@ export const add = new Command() ) .option('-a, --all', 'add all available components', false) .option('-p, --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) => { try { const options = addOptionsSchema.parse({ components, + cwd: path.resolve(opts.cwd), ...opts, }) - const cwd = path.resolve(options.cwd) - - if (!existsSync(cwd)) { - consola.error(`The path ${cwd} does not exist. Please try again.`) - process.exit(1) - } - - const config = await getConfig(cwd) - - if (!config) { - consola.warn(`Configuration is missing. Please run ${colors.green('init')} to create a components.json file.`) - - process.exit(1) - } - - const registryIndex = await getRegistryIndex() - - let selectedComponents = options.all - ? registryIndex.map(entry => entry.name) - : options.components - if (!options.components?.length && !options.all) { - const { components } = await prompts({ - type: 'multiselect', - name: 'components', - message: 'Which components would you like to add?', - hint: 'Space to select. A to toggle all. Enter to submit.', - instructions: false, - choices: registryIndex.map(entry => ({ - title: entry.name, - value: entry.name, - selected: options.all - ? true - : options.components?.includes(entry.name), - })), + // 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?', + ), }) - selectedComponents = components + if (!confirm) { + logger.break() + logger.log('Theme installation cancelled.') + logger.break() + process.exit(1) + } } - if (!selectedComponents?.length) { - consola.warn('No components selected. Exiting.') - process.exit(0) + if (!options.components?.length) { + options.components = await promptForRegistryComponents(options) } - const tree = await resolveTree(registryIndex, selectedComponents) - const payload = await fetchTree(config.style, tree) - const baseColor = await getRegistryBaseColor(config.tailwind.baseColor) + let { errors, config } = await preFlightAdd(options) - if (!payload.length) { - consola.warn('Selected components not found. Exiting.') - process.exit(0) - } - - if (!options.yes) { + // No components.json file. Prompt the user to run init. + if (errors[ERRORS.MISSING_CONFIG]) { const { proceed } = await prompts({ type: 'confirm', name: 'proceed', - message: 'Ready to install components and dependencies. Proceed?', + message: `You need to create a ${highlighter.info( + 'components.json', + )} file to add components. Proceed?`, initial: true, }) - if (!proceed) - process.exit(0) + if (!proceed) { + logger.break() + process.exit(1) + } + + config = await runInit({ + cwd: options.cwd, + yes: true, + force: true, + defaults: false, + skipPreflight: false, + silent: true, + isNewProject: false, + srcDir: options.srcDir, + }) } - 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 (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) { + throw new Error( + `Failed to read config at ${highlighter.info(options.cwd)}.`, ) - - 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.') + + await addComponents(options.components, config, options) } catch (error) { + logger.break() handleError(error) } }) + +async function promptForRegistryComponents( + options: z.infer, +) { + 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 + } + + const { components } = await prompts({ + type: 'multiselect', + name: 'components', + message: 'Which components would you like to add?', + hint: 'Space to select. A to toggle all. Enter to submit.', + instructions: false, + choices: registryIndex + .filter(entry => entry.type === 'registry:ui') + .map(entry => ({ + title: entry.name, + value: entry.name, + selected: options.all ? true : options.components?.includes(entry.name), + })), + }) + + if (!components?.length) { + logger.warn('No components selected. Exiting.') + logger.info('') + process.exit(1) + } + + const result = z.array(z.string()).safeParse(components) + if (!result.success) { + logger.error('') + handleError(new Error('Something went wrong. Please try again.')) + return [] + } + return result.data +} diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts index ccea7540..525c5643 100644 --- a/packages/cli/src/commands/diff.ts +++ b/packages/cli/src/commands/diff.ts @@ -1,9 +1,11 @@ import type { Config } from '@/src/utils/get-config' import type { registryIndexSchema } from '@/src/utils/registry/schema' 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 { handleError } from '@/src/utils/handle-error' +import { highlighter } from '@/src/utils/highlighter' +import { logger } from '@/src/utils/logger' import { fetchTree, getItemTargetPath, @@ -12,10 +14,7 @@ import { } from '@/src/utils/registry' import { transform } from '@/src/utils/transformers' import { Command } from 'commander' -import { consola } from 'consola' -import { colors } from 'consola/utils' import { type Change, diffLines } from 'diff' -import path from 'pathe' import { z } from 'zod' const updateOptionsSchema = z.object({ @@ -45,15 +44,15 @@ export const diff = new Command() const cwd = path.resolve(options.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) } const config = await getConfig(cwd) if (!config) { - consola.warn( - `Configuration is missing. Please run ${colors.green( - 'init', + logger.warn( + `Configuration is missing. Please run ${highlighter.success( + `init`, )} to create a components.json file.`, ) process.exit(1) @@ -61,15 +60,24 @@ export const diff = new Command() const registryIndex = await getRegistryIndex() + if (!registryIndex) { + handleError(new Error('Failed to fetch registry index.')) + process.exit(1) + } + if (!options.component) { const targetDir = config.resolvedPaths.components // Find all components that exist in the project. const projectComponents = registryIndex.filter((item) => { - for (const file of item.files) { - const filePath = path.resolve(targetDir, file) - if (existsSync(filePath)) + for (const file of item.files ?? []) { + const filePath = path.resolve( + targetDir, + typeof file === 'string' ? file : file.path, + ) + if (existsSync(filePath)) { return true + } } return false @@ -88,20 +96,20 @@ export const diff = new Command() } if (!componentsWithUpdates.length) { - consola.info('No updates found.') + logger.info('No updates found.') process.exit(0) } - consola.info('The following components have updates available:') + logger.info('The following components have updates available:') for (const component of componentsWithUpdates) { - consola.info(`- ${component.name}`) - for (const change of component.changes) - consola.info(` - ${change.filePath}`) + logger.info(`- ${component.name}`) + for (const change of component.changes) { + logger.info(` - ${change.filePath}`) + } } - - consola.log('') - consola.info( - `Run ${colors.green('diff ')} to see the changes.`, + logger.break() + logger.info( + `Run ${highlighter.success(`diff `)} to see the changes.`, ) process.exit(0) } @@ -112,8 +120,10 @@ export const diff = new Command() ) if (!component) { - consola.error( - `The component ${colors.green(options.component)} does not exist.`, + logger.error( + `The component ${highlighter.success( + options.component, + )} does not exist.`, ) process.exit(1) } @@ -121,14 +131,14 @@ export const diff = new Command() const changes = await diffComponent(component, config) if (!changes.length) { - consola.info(`No updates found for ${options.component}.`) + logger.info(`No updates found for ${options.component}.`) process.exit(0) } for (const change of changes) { - consola.info(`- ${change.filePath}`) - printDiff(change.patch) - consola.log('') + logger.info(`- ${change.filePath}`) + await printDiff(change.patch) + logger.info('') } } catch (error) { @@ -143,24 +153,37 @@ async function diffComponent( const payload = await fetchTree(config.style, [component]) const baseColor = await getRegistryBaseColor(config.tailwind.baseColor) + if (!payload) { + return [] + } + const changes = [] for (const item of payload) { const targetDir = await getItemTargetPath(config, item) - if (!targetDir) + if (!targetDir) { continue + } - for (const file of item.files) { - const filePath = path.resolve(targetDir, file.name) + for (const file of item.files ?? []) { + const filePath = path.resolve( + targetDir, + typeof file === 'string' ? file : file.path, + ) - if (!existsSync(filePath)) + if (!existsSync(filePath)) { continue + } const fileContent = await fs.readFile(filePath, 'utf8') + if (typeof file === 'string' || !file.content) { + continue + } + const registryContent = await transform({ - filename: file.name, + filename: file.path, raw: file.content, config, baseColor, @@ -169,7 +192,6 @@ async function diffComponent( const patch = diffLines(registryContent as string, fileContent) if (patch.length > 1) { changes.push({ - file: file.name, filePath, patch, }) @@ -180,15 +202,15 @@ async function diffComponent( return changes } -// TODO: Does is it need to async? -function printDiff(diff: Change[]) { +async function printDiff(diff: Change[]) { diff.forEach((part) => { if (part) { - if (part.added) - return process.stdout.write(colors.green(part.value)) - - if (part.removed) - return process.stdout.write(colors.red(part.value)) + if (part.added) { + return process.stdout.write(highlighter.success(part.value)) + } + if (part.removed) { + return process.stdout.write(highlighter.error(part.value)) + } return process.stdout.write(part.value) } diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts new file mode 100644 index 00000000..981e0338 --- /dev/null +++ b/packages/cli/src/commands/info.ts @@ -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 ', + '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)) + }) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index a88d96e0..d302c709 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,122 +1,180 @@ -import { existsSync, promises as fs } from 'node:fs' -import process from 'node:process' -import { Command } from 'commander' -import { consola } from 'consola' -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 { promises as fs } from 'node:fs' +import path from 'node:path' +import { preFlightInit } from '@/src/preflights/preflight-init' +import { addComponents } from '@/src/utils/add-components' import { type Config, DEFAULT_COMPONENTS, DEFAULT_TAILWIND_CONFIG, + DEFAULT_TAILWIND_CSS, DEFAULT_UTILS, getConfig, rawConfigSchema, resolveConfigPaths, - TAILWIND_CSS_PATH, -} from '../utils/get-config' -import { getProjectInfo } from '../utils/get-project-info' -import { handleError } from '../utils/handle-error' -import { - getRegistryBaseColor, - getRegistryBaseColors, - getRegistryStyles, -} from '../utils/registry' -import * as templates from '../utils/templates' -import { transformCJSToESM } from '../utils/transformers/transform-cjs-to-esm' -import { transformByDetype } from '../utils/transformers/transform-sfc' -import { applyPrefixesCss } from '../utils/transformers/transform-tw-prefix' +} from '@/src/utils/get-config' +import { getProjectConfig, getProjectInfo } from '@/src/utils/get-project-info' +import { handleError } from '@/src/utils/handle-error' +import { highlighter } from '@/src/utils/highlighter' +import { logger } from '@/src/utils/logger' +import { getRegistryBaseColors, getRegistryStyles } from '@/src/utils/registry' +import { spinner } from '@/src/utils/spinner' +import { updateTailwindContent } from '@/src/utils/updaters/update-tailwind-content' +import { Command } from 'commander' +import prompts from 'prompts' +import { z } from 'zod' -const PROJECT_DEPENDENCIES = { - base: [ - 'tailwindcss-animate', - 'class-variance-authority', - 'clsx', - 'tailwind-merge', - 'reka-ui', - ], -} - -const initOptionsSchema = z.object({ +export const initOptionsSchema = z.object({ cwd: z.string(), + components: z.array(z.string()).optional(), yes: z.boolean(), + defaults: z.boolean(), + force: z.boolean(), + silent: z.boolean(), + isNewProject: z.boolean(), + srcDir: z.boolean().optional(), }) export const init = new Command() .name('init') .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( '-c, --cwd ', 'the working directory. defaults to the current directory.', process.cwd(), ) - .action(async (opts) => { + .option('-s, --silent', 'mute output.', false) + .option( + '--src-dir', + 'use the src directory when creating a new project.', + false, + ) + .action(async (components, opts) => { try { - const options = initOptionsSchema.parse(opts) - const cwd = path.resolve(options.cwd) + const options = initOptionsSchema.parse({ + cwd: path.resolve(opts.cwd), + isNewProject: false, + components, + ...opts, + }) - // Ensure target directory exists. - if (!existsSync(cwd)) { - consola.error(`The path ${cwd} does not exist. Please try again.`) - process.exit(1) - } + await runInit(options) - // 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.`, + logger.log( + `${highlighter.success( + 'Success!', + )} Project initialization completed.\nYou may now add components.`, ) - consola.log('') + logger.break() } catch (error) { + logger.break() handleError(error) } }) -export async function promptForConfig( - cwd: string, - defaultConfig: Config | null = null, - skip = false, +export async function runInit( + options: z.infer & { + skipPreflight?: boolean + }, ) { - 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 baseColors = await getRegistryBaseColors() + const projectConfig = await getProjectConfig(options.cwd, projectInfo) + 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([ { type: 'toggle', 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, active: 'yes', inactive: 'no', }, - { - type: 'select', - name: 'framework', - message: `Which ${highlight('framework')} are you using?`, - choices: [ - { title: 'Vite', value: 'vite' }, - { title: 'Nuxt', value: 'nuxt' }, - { title: 'Laravel', value: 'laravel' }, - { title: 'Astro', value: 'astro' }, - ], - }, { type: 'select', name: 'style', - message: `Which ${highlight('style')} would you like to use?`, + message: `Which ${highlighter.info('style')} would you like to use?`, choices: styles.map(style => ({ title: style.label, value: style.name, @@ -125,7 +183,7 @@ export async function promptForConfig( { type: 'select', 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', )}?`, choices: baseColors.map(color => ({ @@ -133,28 +191,18 @@ export async function promptForConfig( value: color.name, })), }, - { - type: 'text', - name: 'tsConfigPath', - message: (prev, values) => `Where is your ${highlight(values.typescript ? 'tsconfig.json' : 'jsconfig.json')} file?`, - initial: (prev, values) => { - const prefix = values.framework === 'nuxt' ? '.nuxt/' : './' - const path = values.typescript ? 'tsconfig.json' : 'jsconfig.json' - return prefix + path - }, - }, { type: 'text', name: 'tailwindCss', - message: `Where is your ${highlight('global CSS')} file? ${colors.gray('(this file will be overwritten)')}`, - initial: (prev, values) => defaultConfig?.tailwind.css ?? TAILWIND_CSS_PATH[values.framework as 'vite' | 'nuxt' | 'laravel' | 'astro'], + message: `Where is your ${highlighter.info('global CSS')} file?`, + initial: defaultConfig?.tailwind.css ?? DEFAULT_TAILWIND_CSS, }, { type: 'toggle', name: 'tailwindCssVariables', - message: `Would you like to use ${highlight( + message: `Would you like to use ${highlighter.info( 'CSS variables', - )} for colors?`, + )} for theming?`, initial: defaultConfig?.tailwind.cssVariables ?? true, active: 'yes', inactive: 'no', @@ -162,7 +210,7 @@ export async function promptForConfig( { type: 'text', name: 'tailwindPrefix', - message: `Are you using a custom ${highlight( + message: `Are you using a custom ${highlighter.info( 'tailwind prefix eg. tw-', )}? (Leave blank if not)`, initial: '', @@ -170,35 +218,30 @@ export async function promptForConfig( { type: 'text', name: 'tailwindConfig', - message: `Where is your ${highlight('tailwind.config')} located? ${colors.gray('(this file will be overwritten)')}`, - initial: (prev, values) => { - if (defaultConfig?.tailwind.config) - return defaultConfig?.tailwind.config - if (values.framework === 'astro') - return 'tailwind.config.mjs' - else return DEFAULT_TAILWIND_CONFIG - }, + message: `Where is your ${highlighter.info( + 'tailwind.config.js', + )} located?`, + initial: defaultConfig?.tailwind.config ?? DEFAULT_TAILWIND_CONFIG, }, { type: 'text', 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, }, { type: 'text', 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, }, ]) - const config = rawConfigSchema.parse({ - $schema: 'https://shadcn-vue.com/schema.json', + return rawConfigSchema.parse({ + $schema: 'https://ui.shadcn.com/schema.json', style: options.style, - typescript: options.typescript, - tsConfigPath: options.tsConfigPath, - framework: options.framework, tailwind: { config: options.tailwindConfig, css: options.tailwindCss, @@ -206,113 +249,79 @@ export async function promptForConfig( cssVariables: options.tailwindCssVariables, prefix: options.tailwindPrefix, }, + typescript: options.typescript, aliases: { utils: options.utils, components: options.components, + // TODO: fix this. + lib: options.components.replace(/\/components$/, 'lib'), + hooks: options.components.replace(/\/components$/, 'hooks'), }, }) - - if (!skip) { - const { proceed } = await prompts({ - type: 'confirm', - name: 'proceed', - message: `Write configuration to ${highlight('components.json')}. Proceed?`, - initial: true, - }) - - if (!proceed) - process.exit(0) - } - - // Write to file. - consola.log('') - const spinner = ora('Writing components.json...').start() - const targetPath = path.resolve(cwd, 'components.json') - await fs.writeFile(targetPath, JSON.stringify(config, null, 2), 'utf8') - spinner.succeed() - - return await resolveConfigPaths(cwd, config) } -export async function runInit(cwd: string, config: Config) { - const spinner = ora('Initializing project...')?.start() +async function promptForMinimalConfig( + defaultConfig: Config, + opts: z.infer, +) { + let style = defaultConfig.style + let baseColor = defaultConfig.tailwind.baseColor + let cssVariables = defaultConfig.tailwind.cssVariables - // 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.`) + 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 } - // 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, + return rawConfigSchema.parse({ + $schema: defaultConfig?.$schema, + style, + tailwind: { + ...defaultConfig?.tailwind, + baseColor, + cssVariables, + }, + aliases: defaultConfig?.aliases, + iconLibrary: defaultConfig?.iconLibrary, }) - - dependenciesSpinner?.succeed() } diff --git a/packages/cli/src/commands/migrate.ts b/packages/cli/src/commands/migrate.ts new file mode 100644 index 00000000..75171047 --- /dev/null +++ b/packages/cli/src/commands/migrate.ts @@ -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 ', + '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) + } + }) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 89a45574..b6eddac8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,29 +1,32 @@ #!/usr/bin/env node -import process from 'node:process' - import { add } from '@/src/commands/add' - import { diff } from '@/src/commands/diff' +import { info } from '@/src/commands/info' 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 packageJson from '../package.json' + process.on('SIGINT', () => process.exit(0)) process.on('SIGTERM', () => process.exit(0)) async function main() { - const packageInfo = await getPackageInfo() - const program = new Command() .name('shadcn-vue') .description('add components and dependencies to your project') .version( - packageInfo.version || '1.0.0', + packageJson.version || '1.0.0', '-v, --version', 'display the version number', ) - program.addCommand(init).addCommand(add).addCommand(diff) + program + .addCommand(init) + .addCommand(add) + .addCommand(diff) + .addCommand(migrate) + .addCommand(info) program.parse() } diff --git a/packages/cli/src/migrations/migrate-icons.test.ts b/packages/cli/src/migrations/migrate-icons.test.ts new file mode 100644 index 00000000..cb2b72c4 --- /dev/null +++ b/packages/cli/src/migrations/migrate-icons.test.ts @@ -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 ( +
+ + +
+ ) + }` + + 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 ( +
+ + +
+ ) + }" + `) + }) + + it('should return null if no radix icons are found', async () => { + const input = ` + import { Something } from "other-package" + + export function Component() { + return
No icons here
+ }` + + expect(await migrateIconsFile(input, 'lucide', 'radix', {})) + .toMatchInlineSnapshot(` + "import { Something } from "other-package" + + export function Component() { + return
No icons here
+ }" + `) + }) + + 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 ( +
+ + + +
+ ) + }` + + 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 ( +
+ + + +
+ ) + }" + `) + }) + + it('should preserve all props and children on icons', async () => { + const input = ` + import { CheckIcon, Cross2Icon } from "@radix-ui/react-icons" + + export function Component() { + return ( +
+ + Child content + + +
+ ) + }` + + 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 ( +
+ + Child content + + +
+ ) + }" + `) + }) +}) diff --git a/packages/cli/src/migrations/migrate-icons.ts b/packages/cli/src/migrations/migrate-icons.ts new file mode 100644 index 00000000..83558fb2 --- /dev/null +++ b/packages/cli/src/migrations/migrate-icons.ts @@ -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, +) { + 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() +} diff --git a/packages/cli/src/preflights/preflight-add.ts b/packages/cli/src/preflights/preflight-add.ts new file mode 100644 index 00000000..7d79ef55 --- /dev/null +++ b/packages/cli/src/preflights/preflight-add.ts @@ -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) { + const errors: Record = {} + + // 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) + } +} diff --git a/packages/cli/src/preflights/preflight-init.ts b/packages/cli/src/preflights/preflight-init.ts new file mode 100644 index 00000000..51f21fbb --- /dev/null +++ b/packages/cli/src/preflights/preflight-init.ts @@ -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) { + const errors: Record = {} + + // 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, + } +} diff --git a/packages/cli/src/preflights/preflight-migrate.ts b/packages/cli/src/preflights/preflight-migrate.ts new file mode 100644 index 00000000..519cbe12 --- /dev/null +++ b/packages/cli/src/preflights/preflight-migrate.ts @@ -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, +) { + const errors: Record = {} + + // 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) + } +} diff --git a/packages/cli/src/utils/add-components.ts b/packages/cli/src/utils/add-components.ts new file mode 100644 index 00000000..3e75a53c --- /dev/null +++ b/packages/cli/src/utils/add-components.ts @@ -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) + } +} diff --git a/packages/cli/src/utils/create-project.ts b/packages/cli/src/utils/create-project.ts new file mode 100644 index 00000000..ed5ce254 --- /dev/null +++ b/packages/cli/src/utils/create-project.ts @@ -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, '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, + } +} diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts new file mode 100644 index 00000000..dcacfd00 --- /dev/null +++ b/packages/cli/src/utils/errors.ts @@ -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' diff --git a/packages/cli/src/utils/frameworks.ts b/packages/cli/src/utils/frameworks.ts new file mode 100644 index 00000000..706ce082 --- /dev/null +++ b/packages/cli/src/utils/frameworks.ts @@ -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] diff --git a/packages/cli/src/utils/get-config.ts b/packages/cli/src/utils/get-config.ts index 7902e779..0d517f61 100644 --- a/packages/cli/src/utils/get-config.ts +++ b/packages/cli/src/utils/get-config.ts @@ -1,5 +1,3 @@ -import type { ConfigLoaderResult } from 'tsconfig-paths' -import { existsSync } from 'node:fs' import { resolveImport } from '@/src/utils/resolve-import' import { loadConfig as c12LoadConfig } from 'c12' import path from 'pathe' @@ -9,9 +7,10 @@ import { z } from 'zod' export const DEFAULT_STYLE = 'default' export const DEFAULT_COMPONENTS = '@/components' 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_BASE_COLOR = 'slate' +export const DEFAULT_TYPESCRIPT_CONFIG = './tsconfig.json' export const TAILWIND_CSS_PATH = { nuxt: 'assets/css/tailwind.css', @@ -25,89 +24,102 @@ export const rawConfigSchema = z $schema: z.string().optional(), style: z.string(), typescript: z.boolean().default(true), - tsConfigPath: z.string().default(DEFAULT_TYPESCRIPT_CONFIG), tailwind: z.object({ config: z.string(), css: z.string(), baseColor: z.string(), cssVariables: z.boolean().default(true), - prefix: z.string().optional(), + prefix: z.string().default('').optional(), }), - framework: z.string().default('Vite'), aliases: z.object({ components: 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() export type RawConfig = z.infer -export const configSchema = rawConfigSchema - .extend({ - resolvedPaths: z.object({ - tailwindConfig: z.string(), - tailwindCss: z.string(), - utils: z.string(), - components: z.string(), - ui: z.string(), - }), - }) +export const configSchema = rawConfigSchema.extend({ + resolvedPaths: z.object({ + cwd: z.string(), + tailwindConfig: z.string(), + tailwindCss: z.string(), + utils: z.string(), + components: z.string(), + lib: z.string(), + hooks: z.string(), + ui: z.string(), + }), +}) export type Config = z.infer export async function getConfig(cwd: string) { const config = await getRawConfig(cwd) - if (!config) + if (!config) { 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) } export async function resolveConfigPaths(cwd: string, config: RawConfig) { - let tsConfig: ConfigLoaderResult | undefined - let tsConfigPath = path.resolve( - cwd, - config.tsConfigPath, - ) + // Read tsconfig.json. + const tsConfig = await loadConfig(cwd) - if (config.typescript) { - // Read tsconfig.json. - tsConfig = loadConfig(tsConfigPath) - // 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') { throw new Error( - `Failed to load ${tsConfigPath}. ${tsConfig.message ?? ''}`.trim(), + `Failed to load ${config.typescript ? 'tsconfig' : 'jsconfig'}.json. ${ + tsConfig.message ?? '' + }`.trim(), ) } return configSchema.parse({ ...config, resolvedPaths: { + cwd, tailwindConfig: path.resolve(cwd, config.tailwind.config), tailwindCss: path.resolve(cwd, config.tailwind.css), - utils: resolveImport(config.aliases.utils, tsConfig), - components: resolveImport(config.aliases.components, tsConfig), + utils: await resolveImport(config.aliases.utils, tsConfig), + components: await resolveImport(config.aliases.components, tsConfig), ui: config.aliases.ui - ? resolveImport(config.aliases.ui, tsConfig) - : resolveImport(config.aliases.components, tsConfig), + ? await resolveImport(config.aliases.ui, 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 { try { const configResult = await c12LoadConfig({ diff --git a/packages/cli/src/utils/get-package-info.ts b/packages/cli/src/utils/get-package-info.ts index a6394d42..9c9f9798 100644 --- a/packages/cli/src/utils/get-package-info.ts +++ b/packages/cli/src/utils/get-package-info.ts @@ -1,16 +1,14 @@ import type { PackageJson } from 'type-fest' -import { fileURLToPath } from 'node:url' +import path from 'node:path' import fs from 'fs-extra' -import path from 'pathe' -export function getPackageInfo() { - const packageJsonPath = getPackageFilePath('../package.json') +export function getPackageInfo( + cwd: string = '', + shouldThrow: boolean = true, +): PackageJson | null { + const packageJsonPath = path.join(cwd, 'package.json') - return fs.readJSONSync(packageJsonPath) as PackageJson -} - -function getPackageFilePath(filePath: string) { - const distPath = fileURLToPath(new URL('.', import.meta.url)) - - return path.resolve(distPath, filePath) + return fs.readJSONSync(packageJsonPath, { + throws: shouldThrow, + }) as PackageJson } diff --git a/packages/cli/src/utils/get-project-info.ts b/packages/cli/src/utils/get-project-info.ts index dab52ce2..2b047d88 100644 --- a/packages/cli/src/utils/get-project-info.ts +++ b/packages/cli/src/utils/get-project-info.ts @@ -1,64 +1,251 @@ -import type { PackageJson } from 'pkg-types' -import { existsSync } from 'node:fs' +import type { Framework } from '@/src/utils/frameworks' +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 path from 'pathe' -import { readPackageJSON } from 'pkg-types' +import { loadConfig } from 'tsconfig-paths' +import { z } from 'zod' -export async function getProjectInfo() { - const info = { - tsconfig: null, - isNuxt: false, - shadcnNuxt: undefined, - isVueVite: false, - srcDir: false, - componentsUiDir: false, - srcComponentsUiDir: false, - } - - try { - const tsconfig = await getTsConfig() - - const isNuxt = existsSync(path.resolve('./nuxt.config.js')) || existsSync(path.resolve('./nuxt.config.ts')) - const shadcnNuxt = isNuxt ? await getShadcnNuxtInfo() : undefined - - return { - tsconfig, - isNuxt, - shadcnNuxt, - isVueVite: existsSync(path.resolve('./vite.config.js')) || existsSync(path.resolve('./vite.config.ts')), - srcDir: existsSync(path.resolve('./src')), - srcComponentsUiDir: existsSync(path.resolve('./src/components/ui')), - componentsUiDir: existsSync(path.resolve('./components/ui')), - } - } - catch (error) { - return info - } +export interface ProjectInfo { + framework: Framework + isSrcDir: boolean + isRSC: boolean + isTsx: boolean + tailwindConfigFile: string | null + tailwindCssFile: string | null + aliasPrefix: string | null } -async function getShadcnNuxtInfo() { - let nuxtModule: PackageJson | undefined - try { - nuxtModule = await readPackageJSON('shadcn-nuxt') - } - catch (error) { - nuxtModule = undefined +const PROJECT_SHARED_IGNORE = [ + '**/node_modules/**', + '.nuxt', + 'public', + 'dist', + 'build', +] + +const TS_CONFIG_SCHEMA = z.object({ + compilerOptions: z.object({ + paths: z.record(z.string().or(z.array(z.string()))), + }), +}) + +export async function getProjectInfo(cwd: string): Promise { + const [ + configFiles, + isSrcDir, + isTsx, + tailwindConfigFile, + tailwindCssFile, + aliasPrefix, + packageJson, + ] = await Promise.all([ + fg.glob('**/{nuxt,vite,astro}.config.*|composer.json', { + 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, } - return nuxtModule + // Nuxt. + if (configFiles.find(file => file.startsWith('nuxt.config.'))?.length) { + type.framework = FRAMEWORKS.nuxt + return type + } + + // Astro. + if (configFiles.find(file => file.startsWith('astro.config.'))?.length) { + type.framework = FRAMEWORKS.astro + return type + } + + // Laravel. + if (configFiles.find(file => file.startsWith('composer.json'))?.length) { + type.framework = FRAMEWORKS.laravel + return type + } + + // 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 getTsConfig() { - try { - const tsconfigPath = path.join('tsconfig.json') - const tsconfig = await fs.readJSON(tsconfigPath) +export async function getTailwindCssFile(cwd: string) { + const files = await fg.glob(['**/*.css', '**/*.scss'], { + cwd, + deep: 5, + ignore: PROJECT_SHARED_IGNORE, + }) - if (!tsconfig) - throw new Error('tsconfig.json is missing') - - return tsconfig - } - catch (error) { + if (!files.length) { 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 { + // 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) } diff --git a/packages/cli/src/utils/highlighter.ts b/packages/cli/src/utils/highlighter.ts new file mode 100644 index 00000000..febc88cd --- /dev/null +++ b/packages/cli/src/utils/highlighter.ts @@ -0,0 +1,8 @@ +import { cyan, green, red, yellow } from 'kleur/colors' + +export const highlighter = { + error: red, + warn: yellow, + info: cyan, + success: green, +} diff --git a/packages/cli/src/utils/icon-libraries.ts b/packages/cli/src/utils/icon-libraries.ts new file mode 100644 index 00000000..56a345c5 --- /dev/null +++ b/packages/cli/src/utils/icon-libraries.ts @@ -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', + }, +} diff --git a/packages/cli/src/utils/logger.ts b/packages/cli/src/utils/logger.ts new file mode 100644 index 00000000..c21b4f4a --- /dev/null +++ b/packages/cli/src/utils/logger.ts @@ -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('') + }, +} diff --git a/packages/cli/src/utils/registry/index.ts b/packages/cli/src/utils/registry/index.ts index 39fa27cd..fb7dbbbe 100644 --- a/packages/cli/src/utils/registry/index.ts +++ b/packages/cli/src/utils/registry/index.ts @@ -1,19 +1,27 @@ import type { Config } from '@/src/utils/get-config' -import type { registryItemWithContentSchema } from '@/src/utils/registry/schema' -import type * as z from 'zod' -import process from 'node:process' +import type { + registryItemFileSchema, +} 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 { + iconsSchema, registryBaseColorSchema, registryIndexSchema, - registryWithContentSchema, + registryItemSchema, + registryResolvedItemsTreeSchema, stylesSchema, } 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 { 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 ? new HttpsProxyAgent(process.env.https_proxy) : undefined @@ -25,7 +33,8 @@ export async function getRegistryIndex() { return registryIndexSchema.parse(result) } 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) } 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 [ { - name: 'slate', - label: 'Slate', + name: 'neutral', + label: 'Neutral', }, { name: 'gray', @@ -54,14 +91,14 @@ export function getRegistryBaseColors() { name: 'zinc', label: 'Zinc', }, - { - name: 'neutral', - label: 'Neutral', - }, { name: 'stone', label: 'Stone', }, + { + name: 'slate', + label: 'Slate', + }, ] } @@ -72,7 +109,7 @@ export async function getRegistryBaseColor(baseColor: string) { return registryBaseColorSchema.parse(result) } 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) { const entry = index.find(entry => entry.name === name) - if (!entry) + if (!entry) { continue + } tree.push(entry) @@ -109,29 +147,30 @@ export async function fetchTree( try { const paths = tree.map(item => `styles/${style}/${item.name}.json`) const result = await fetchRegistry(paths) - - return registryWithContentSchema.parse(result) + return registryIndexSchema.parse(result) } catch (error) { - throw new Error('Failed to fetch tree from registry.') + handleError(error) } } -export function getItemTargetPath( +export async function getItemTargetPath( config: Config, - item: Pick, 'type'>, + item: Pick, 'type'>, override?: string, ) { - // Allow overrides for all items but ui. - if (override) + if (override) { return override + } - if (item.type === 'components:ui' && config.aliases.ui) - return config.resolvedPaths.ui + if (item.type === 'registry:ui') { + return config.resolvedPaths.ui ?? config.resolvedPaths.components + } - const [parent, type] = item.type.split(':') - if (!(parent in config.resolvedPaths)) + const [parent, type] = item.type?.split(':') ?? [] + if (!(parent in config.resolvedPaths)) { return null + } return path.join( config.resolvedPaths[parent as keyof typeof config.resolvedPaths], @@ -143,18 +182,295 @@ async function fetchRegistry(paths: string[]) { try { const results = await Promise.all( paths.map(async (path) => { - const response = await ofetch(`${baseUrl}/registry/${path}`, { - // @ts-expect-error agent type - agent, - }) + const url = getRegistryUrl(path) + const response = await ofetch(url, { 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 } catch (error) { - consola.error(error) - throw new Error(`Failed to fetch registry from ${baseUrl}.`) + logger.error('\n') + handleError(error) + return [] + } +} + +export function getRegistryItemFileTargetPath( + file: z.infer, + 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['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 { + const visited = new Set() + 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 + + 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 } } diff --git a/packages/cli/src/utils/registry/schema.ts b/packages/cli/src/utils/registry/schema.ts index 14f0383c..f1063841 100644 --- a/packages/cli/src/utils/registry/schema.ts +++ b/packages/cli/src/utils/registry/schema.ts @@ -1,27 +1,61 @@ import { z } from 'zod' // 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({ name: z.string(), + type: registryItemTypeSchema, + description: z.string().optional(), dependencies: z.array(z.string()).optional(), devDependencies: z.array(z.string()).optional(), registryDependencies: z.array(z.string()).optional(), - files: z.array(z.string()), - type: z.enum(['components:ui', 'components:component', 'components:example']), + files: z.array(registryItemFileSchema).optional(), + 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 -export const registryItemWithContentSchema = registryItemSchema.extend({ - files: z.array( - z.object({ - name: z.string(), - content: z.string(), - }), - ), -}) - -export const registryWithContentSchema = z.array(registryItemWithContentSchema) +export const registryIndexSchema = z.array( + registryItemSchema.extend({ + files: z.array(z.union([z.string(), registryItemFileSchema])).optional(), + }), +) export const stylesSchema = z.array( 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({ inlineColors: z.object({ light: z.record(z.string(), z.string()), @@ -42,3 +81,12 @@ export const registryBaseColorSchema = z.object({ inlineColorsTemplate: z.string(), cssVarsTemplate: z.string(), }) + +export const registryResolvedItemsTreeSchema = registryItemSchema.pick({ + dependencies: true, + devDependencies: true, + files: true, + tailwind: true, + cssVars: true, + docs: true, +}) diff --git a/packages/cli/src/utils/spinner.ts b/packages/cli/src/utils/spinner.ts new file mode 100644 index 00000000..4af0c6bc --- /dev/null +++ b/packages/cli/src/utils/spinner.ts @@ -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, + }) +} diff --git a/packages/cli/src/utils/updaters/update-css-vars.ts b/packages/cli/src/utils/updaters/update-css-vars.ts new file mode 100644 index 00000000..f9dc61f8 --- /dev/null +++ b/packages/cli/src/utils/updaters/update-css-vars.ts @@ -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 | 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, + 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, +) { + 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, +) { + 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) + }) +} diff --git a/packages/cli/src/utils/updaters/update-dependencies.ts b/packages/cli/src/utils/updaters/update-dependencies.ts new file mode 100644 index 00000000..eb747505 --- /dev/null +++ b/packages/cli/src/utils/updaters/update-dependencies.ts @@ -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() +} diff --git a/packages/cli/src/utils/updaters/update-files.ts b/packages/cli/src/utils/updaters/update-files.ts new file mode 100644 index 00000000..2b226afe --- /dev/null +++ b/packages/cli/src/utils/updaters/update-files.ts @@ -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>, + 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() + } +} diff --git a/packages/cli/src/utils/updaters/update-tailwind-config.ts b/packages/cli/src/utils/updaters/update-tailwind-config.ts new file mode 100644 index 00000000..633a3ebf --- /dev/null +++ b/packages/cli/src/utils/updaters/update-tailwind-config.ts @@ -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 & { + // We only want string plugins for now. + plugins?: string[] +} + +export async function updateTailwindConfig( + tailwindConfig: + | z.infer['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 { + 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, +) { + const result: Record = {} + + 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 +} diff --git a/packages/cli/src/utils/updaters/update-tailwind-content.ts b/packages/cli/src/utils/updaters/update-tailwind-content.ts new file mode 100644 index 00000000..999220e0 --- /dev/null +++ b/packages/cli/src/utils/updaters/update-tailwind-content.ts @@ -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 +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index fde34990..f1943d67 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -7,6 +7,7 @@ "paths": { "@/*": ["./*"] }, + "resolveJsonModule": true, "isolatedModules": false }, "include": ["src/**/*.ts"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3121ff52..97350e40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,15 +264,24 @@ importers: consola: specifier: ^3.2.3 version: 3.2.3 + deepmerge: + specifier: ^4.3.1 + version: 4.3.1 diff: specifier: ^7.0.0 version: 7.0.0 + fast-glob: + specifier: ^3.3.2 + version: 3.3.2 fs-extra: specifier: ^11.2.0 version: 11.2.0 https-proxy-agent: specifier: ^7.0.5 version: 7.0.5(supports-color@9.4.0) + kleur: + specifier: ^4.1.5 + version: 4.1.5 lodash-es: specifier: ^4.17.21 version: 4.17.21 @@ -294,6 +303,9 @@ importers: pkg-types: specifier: ^1.2.1 version: 1.2.1 + postcss: + specifier: ^8.4.49 + version: 8.4.49 prompts: specifier: ^2.4.2 version: 2.4.2 @@ -303,6 +315,15 @@ importers: semver: specifier: ^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: specifier: ^4.2.0 version: 4.2.0 @@ -331,6 +352,9 @@ importers: '@types/prompts': specifier: ^2.4.9 version: 2.4.9 + '@types/stringify-object': + specifier: ^4.0.5 + version: 4.0.5 tsup: 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) @@ -2100,6 +2124,9 @@ packages: resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} 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': resolution: {integrity: sha512-loB369iXNmAZglwWATL+WRe+CRMmmBPtpolYzIebFaX4YA3x+BEfLqhUAV9WanycKI3TG1IMr5bMJDajDKLlUQ==} @@ -2298,6 +2325,9 @@ packages: '@types/responselike@1.0.3': 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': resolution: {integrity: sha512-XMSqQEr7YDuNtFwSgaHHOjsbi0ZGL62V9Js4CW45RBuRYlNWSW/KDqN+RFFE7HdHcGhJPtN0klKvw06r9Kg7rg==} @@ -3142,6 +3172,9 @@ packages: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} 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: resolution: {integrity: sha512-Qj00p60oNExthP2oR3vvXmUGjukij+rxJGuiaKM6tyUmSyimdZsqHI/TUvFFClAffk9s7hxGnQgWQ8KCce27qQ==} @@ -4374,6 +4407,10 @@ packages: resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} 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: resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==} @@ -4824,6 +4861,10 @@ packages: resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} 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: resolution: {integrity: sha512-qhsCR/Esx4U4hg/9I19OVUAJkGWtjRYHMRgUMZE2TDdj+Ag+kttZanLupfddNyglzz50cUlmWzUaI37GDfNx/g==} engines: {node: '>=0.10.0'} @@ -4839,6 +4880,10 @@ packages: is-reference@1.2.1: 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: resolution: {integrity: sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==} engines: {node: '>=0.10.0'} @@ -5009,6 +5054,10 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + klona@2.0.6: resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} engines: {node: '>= 8'} @@ -6817,6 +6866,10 @@ packages: stringify-entities@4.0.4: 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: resolution: {integrity: sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==} engines: {node: '>=4'} @@ -7097,6 +7150,9 @@ packages: ts-interface-checker@0.1.13: 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: resolution: {integrity: sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==} engines: {node: ^18 || >=20} @@ -9652,6 +9708,12 @@ snapshots: '@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': dependencies: '@types/node': 22.9.0 @@ -9880,6 +9942,8 @@ snapshots: dependencies: '@types/node': 22.9.0 + '@types/stringify-object@4.0.5': {} + '@types/supercluster@5.0.3': dependencies: '@types/geojson': 7946.0.14 @@ -10979,6 +11043,8 @@ snapshots: co@4.6.0: {} + code-block-writer@13.0.3: {} + codesandbox-import-util-types@2.2.3: {} codesandbox-import-utils@2.2.3: @@ -12379,6 +12445,8 @@ snapshots: get-east-asian-width@1.3.0: {} + get-own-enumerable-keys@1.0.0: {} + get-port-please@3.1.2: {} get-stream@3.0.0: {} @@ -12875,6 +12943,8 @@ snapshots: is-obj@2.0.0: {} + is-obj@3.0.0: {} + is-path-inside@1.0.1: dependencies: path-is-inside: 1.0.2 @@ -12887,6 +12957,8 @@ snapshots: dependencies: '@types/estree': 1.0.6 + is-regexp@3.1.0: {} + is-retry-allowed@1.2.0: {} is-ssh@1.4.0: @@ -13016,6 +13088,8 @@ snapshots: kleur@3.0.3: {} + kleur@4.1.5: {} + klona@2.0.6: {} knitwork@1.1.0: {} @@ -15301,6 +15375,12 @@ snapshots: character-entities-html4: 2.1.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: dependencies: ansi-regex: 3.0.1 @@ -15611,6 +15691,11 @@ snapshots: 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): optionalDependencies: typescript: 5.6.3