diff --git a/.changeset/lovely-clouds-cheer.md b/.changeset/lovely-clouds-cheer.md new file mode 100644 index 000000000..0c6aae10b --- /dev/null +++ b/.changeset/lovely-clouds-cheer.md @@ -0,0 +1,5 @@ +--- +"shadcn-svelte": patch +--- + +chore: Improved printed error messages diff --git a/.changeset/rude-seahorses-own.md b/.changeset/rude-seahorses-own.md new file mode 100644 index 000000000..1bcf4d78b --- /dev/null +++ b/.changeset/rude-seahorses-own.md @@ -0,0 +1,5 @@ +--- +"shadcn-svelte": patch +--- + +chore: Certain config files can now be auto-detected and suggested during `init` diff --git a/packages/cli/package.json b/packages/cli/package.json index 88899fe62..da6874f84 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -30,7 +30,7 @@ "radix-svelte" ], "scripts": { - "dev": "tsup --watch", + "dev": "tsup --watch --sourcemap", "build": "tsup --minify", "check": "tsc --noEmit", "start:dev": "cross-env COMPONENTS_REGISTRY_URL=http://localhost:5173 node dist/index.js", @@ -54,6 +54,7 @@ "@types/node": "^18.19.22", "cross-env": "^7.0.3", "get-tsconfig": "^4.7.3", + "ignore": "^5.3.1", "prettier": "^3.0.0", "sisteransi": "^1.0.5", "tsup": "^8.0.0", diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index c6cc1894c..b7297e862 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -7,7 +7,7 @@ import * as v from "valibot"; import { getConfig, type Config } from "../utils/get-config.js"; import { getEnvProxy } from "../utils/get-env-proxy.js"; import { getPackageManager } from "../utils/get-package-manager.js"; -import { error, handleError } from "../utils/handle-error.js"; +import { ConfigError, error, handleError } from "../utils/errors.js"; import { fetchTree, getItemTargetPath, @@ -42,7 +42,7 @@ export const add = new Command() .option("-a, --all", "install all components to your project.", false) .option("-y, --yes", "skip confirmation prompt.", false) .option("-o, --overwrite", "overwrite existing files.", false) - .option("--proxy ", "fetch components from registry using a proxy.") + .option("--proxy ", "fetch components from registry using a proxy.", getEnvProxy()) .option( "-c, --cwd ", "the working directory. defaults to the current directory.", @@ -65,7 +65,7 @@ export const add = new Command() const config = await getConfig(cwd); if (!config) { - throw error( + throw new ConfigError( `Configuration file is missing. Please run ${color.green("init")} to create a ${highlight("components.json")} file.` ); } @@ -79,14 +79,9 @@ export const add = new Command() }); async function runAdd(cwd: string, config: Config, options: AddOptions) { - const proxy = options.proxy ?? getEnvProxy(); - if (proxy) { - const isCustom = !!options.proxy; - if (isCustom) process.env.HTTP_PROXY = options.proxy; - - p.log.warn( - `You are using a ${isCustom ? "provided" : "system environment"} proxy: ${color.green(proxy)}` - ); + if (options.proxy !== undefined) { + process.env.HTTP_PROXY = options.proxy; + p.log.info(`You are using the provided proxy: ${color.green(options.proxy)}`); } const registryIndex = await getRegistryIndex(); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index a09c5029e..35e7036b5 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -7,13 +7,14 @@ import { execa } from "execa"; import * as cliConfig from "../utils/get-config.js"; import type { Config } from "../utils/get-config.js"; import { getPackageManager } from "../utils/get-package-manager.js"; -import { error, handleError } from "../utils/handle-error.js"; +import { error, handleError } from "../utils/errors.js"; import { getRegistryBaseColor, getRegistryBaseColors, getRegistryStyles } from "../utils/registry"; import * as templates from "../utils/templates.js"; import * as p from "../utils/prompts.js"; import { intro } from "../utils/prompt-helpers.js"; import { resolveImport } from "../utils/resolve-imports.js"; -import { syncSvelteKit } from "../utils/sync-sveltekit.js"; +import { syncSvelteKit } from "../utils/sveltekit.js"; +import { detectConfigs } from "../utils/auto-detect.js"; const PROJECT_DEPENDENCIES = ["tailwind-variants", "clsx", "tailwind-merge"] as const; const highlight = (...args: unknown[]) => color.bold.cyan(...args); @@ -48,12 +49,13 @@ export const init = new Command() } }); -async function promptForConfig(cwd: string, defaultConfig: Config | null = null) { +async function promptForConfig(cwd: string, defaultConfig: Config | null) { // if it's a SvelteKit project, run sync so that the aliases are always up to date await syncSvelteKit(cwd); const styles = await getRegistryStyles(); const baseColors = await getRegistryBaseColors(); + const detectedConfigs = detectConfigs(cwd, { relative: true }); const typescript = await p.confirm({ message: `Would you like to use ${highlight("TypeScript")} (recommended)?`, @@ -100,10 +102,13 @@ async function promptForConfig(cwd: string, defaultConfig: Config | null = null) tailwindCss: () => p.text({ message: `Where is your ${highlight("global CSS")} file?`, - initialValue: defaultConfig?.tailwind.css ?? cliConfig.DEFAULT_TAILWIND_CSS, - placeholder: cliConfig.DEFAULT_TAILWIND_CSS, + initialValue: + defaultConfig?.tailwind.css ?? + detectedConfigs.cssPath ?? + cliConfig.DEFAULT_TAILWIND_CSS, + placeholder: detectedConfigs.cssPath ?? cliConfig.DEFAULT_TAILWIND_CSS, validate: (value) => { - if (existsSync(path.resolve(cwd, value))) { + if (value && existsSync(path.resolve(cwd, value))) { return; } return `"${color.bold(value)}" does not exist. Please enter a valid path.`; @@ -112,10 +117,13 @@ async function promptForConfig(cwd: string, defaultConfig: Config | null = null) tailwindConfig: () => p.text({ message: `Where is your ${highlight("Tailwind config")} located?`, - initialValue: defaultConfig?.tailwind.config ?? cliConfig.DEFAULT_TAILWIND_CONFIG, - placeholder: cliConfig.DEFAULT_TAILWIND_CONFIG, + initialValue: + defaultConfig?.tailwind.config ?? + detectedConfigs.tailwindPath ?? + cliConfig.DEFAULT_TAILWIND_CONFIG, + placeholder: detectedConfigs.tailwindPath ?? cliConfig.DEFAULT_TAILWIND_CONFIG, validate: (value) => { - if (existsSync(path.resolve(cwd, value))) { + if (value && existsSync(path.resolve(cwd, value))) { return; } return `"${color.bold(value)}" does not exist. Please enter a valid path.`; @@ -124,14 +132,18 @@ async function promptForConfig(cwd: string, defaultConfig: Config | null = null) components: () => p.text({ message: `Configure the import alias for ${highlight("components")}:`, - initialValue: defaultConfig?.aliases["components"] ?? cliConfig.DEFAULT_COMPONENTS, + initialValue: defaultConfig?.aliases.components ?? cliConfig.DEFAULT_COMPONENTS, placeholder: cliConfig.DEFAULT_COMPONENTS, validate: validateImportAlias, }), - utils: () => + utils: ({ results: { components } }) => p.text({ message: `Configure the import alias for ${highlight("utils")}:`, - initialValue: defaultConfig?.aliases["utils"] ?? cliConfig.DEFAULT_UTILS, + initialValue: + defaultConfig?.aliases.utils ?? + // infers the alias from `components`. if `components = @/comps` then suggest `utils = @/utils` + components?.split("/").slice(0, -1).join("/") + "/utils" ?? + cliConfig.DEFAULT_UTILS, placeholder: cliConfig.DEFAULT_UTILS, validate: validateImportAlias, }), @@ -159,8 +171,10 @@ async function promptForConfig(cwd: string, defaultConfig: Config | null = null) }, }); + // Delete `tailwind.config.cjs` and rename to `.js` if (config.tailwind.config.endsWith(".cjs")) { p.log.info(`Your tailwind config has been renamed to ${highlight("tailwind.config.js")}.`); + await fs.unlink(config.tailwind.config).catch(() => null); const renamedTailwindConfigPath = config.tailwind.config.replace(".cjs", ".js"); config.tailwind.config = renamedTailwindConfigPath; } @@ -209,10 +223,6 @@ export async function runInit(cwd: string, config: Config) { "utf8" ); - // Delete tailwind.config.cjs, if present - const cjsConfig = config.resolvedPaths.tailwindConfig.replace(".js", ".cjs"); - if (cjsConfig.endsWith(".cjs")) await fs.unlink(cjsConfig).catch((e) => e); // throws when it DNE - // Write css file. const baseColor = await getRegistryBaseColor(config.tailwind.baseColor); if (baseColor) { diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts index 41cec2573..58e2238ae 100644 --- a/packages/cli/src/commands/update.ts +++ b/packages/cli/src/commands/update.ts @@ -6,7 +6,7 @@ import { execa } from "execa"; import * as v from "valibot"; import { getConfig, type Config } from "../utils/get-config.js"; import { getPackageManager } from "../utils/get-package-manager.js"; -import { handleError, error } from "../utils/handle-error.js"; +import { handleError, error } from "../utils/errors.js"; import { fetchTree, getItemTargetPath, getRegistryIndex, resolveTree } from "../utils/registry"; import { UTILS } from "../utils/templates.js"; import { transformImports } from "../utils/transformers.js"; @@ -32,7 +32,7 @@ export const update = new Command() .argument("[components...]", "name of components") .option("-a, --all", "update all existing components.", false) .option("-y, --yes", "skip confirmation prompt.", false) - .option("--proxy ", "fetch components from registry using a proxy.") + .option("--proxy ", "fetch components from registry using a proxy.", getEnvProxy()) .option( "-c, --cwd ", "the working directory. defaults to the current directory.", @@ -73,18 +73,12 @@ export const update = new Command() }); async function runUpdate(cwd: string, config: Config, options: UpdateOptions) { - const components = options.components; - - const proxy = options.proxy ?? getEnvProxy(); - if (proxy) { - const isCustom = !!options.proxy; - if (isCustom) process.env.HTTP_PROXY = options.proxy; - - p.log.warn( - `You are using a ${isCustom ? "provided" : "system environment"} proxy: ${color.green(proxy)}` - ); + if (options.proxy !== undefined) { + process.env.HTTP_PROXY = options.proxy; + p.log.info(`You are using the provided proxy: ${color.green(options.proxy)}`); } + const components = options.components; const registryIndex = await getRegistryIndex(); const componentDir = path.resolve(config.resolvedPaths.components, "ui"); diff --git a/packages/cli/src/utils/auto-detect.ts b/packages/cli/src/utils/auto-detect.ts new file mode 100644 index 000000000..2e2805d48 --- /dev/null +++ b/packages/cli/src/utils/auto-detect.ts @@ -0,0 +1,68 @@ +import fs from "node:fs"; +import path from "node:path"; +import ignore, { type Ignore } from "ignore"; + +const STYLESHEETS = ["app.css", "app.pcss", "app.postcss", "main.css", "main.pcss", "main.postcss"]; +const TAILWIND_CONFIGS = [ + "tailwind.config.js", + "tailwind.config.cjs", + "tailwind.config.mjs", + "tailwind.config.ts", +]; + +// commonly ignored +const IGNORE = ["node_modules", ".git", ".svelte-kit"]; + +export function detectConfigs(cwd: string, config?: { relative: boolean }) { + let tailwindPath, cssPath; + const paths = findFiles(cwd); + for (const filepath of paths) { + const filename = path.parse(filepath).base; + if (cssPath === undefined && STYLESHEETS.includes(filename)) { + cssPath = config?.relative ? path.relative(cwd, filepath) : filepath; + } + if (tailwindPath === undefined && TAILWIND_CONFIGS.includes(filename)) { + tailwindPath = config?.relative ? path.relative(cwd, filepath) : filepath; + } + } + return { tailwindPath, cssPath }; +} + +/** + * Walks down the directory tree, returning file paths that are _not_ ignored from their respective `.gitignore`'s + */ +function findFiles(dirPath: string) { + return find(dirPath, []); +} + +function find(dirPath: string, ignores: { dirPath: string; ig: Ignore }[]): string[] { + const paths: string[] = []; + const files = fs.readdirSync(dirPath, { withFileTypes: true }); + const ignorePath = path.join(dirPath, ".gitignore"); + if (fs.existsSync(ignorePath)) { + const gitignore = fs.readFileSync(ignorePath, { encoding: "utf8" }); + const ig = ignore().add(gitignore); + ignores.push({ dirPath, ig }); + } + + for (const file of files) { + const filepath = path.join(file.path, file.name); + // ignore any of the common suspects + if (IGNORE.some((name) => file.path.includes(name))) continue; + + // check if file is ignored + const ignored = ignores.some((parent) => { + // make the path relative to the parent + const relative = path.relative(parent.dirPath, filepath); + if (ignore.isPathValid(relative) === false) return; + + return parent.ig.ignores(relative); + }); + if (ignored) continue; + + if (file.isFile()) paths.push(filepath); + if (file.isDirectory()) paths.push(...find(filepath, ignores)); + } + + return paths; +} diff --git a/packages/cli/src/utils/handle-error.ts b/packages/cli/src/utils/errors.ts similarity index 52% rename from packages/cli/src/utils/handle-error.ts rename to packages/cli/src/utils/errors.ts index cebc71c9a..ecda881f6 100644 --- a/packages/cli/src/utils/handle-error.ts +++ b/packages/cli/src/utils/errors.ts @@ -9,8 +9,14 @@ export function handleError(error: unknown) { process.exit(1); } + if (error instanceof CLIError || error instanceof ConfigError) { + p.cancel(`[${error.name}]: ${error.message}`); + process.exit(1); + } + + // unexpected error if (error instanceof Error) { - p.cancel(error.message); + p.cancel(error.stack); process.exit(1); } @@ -19,5 +25,13 @@ export function handleError(error: unknown) { } export function error(msg: string) { - return new Error(msg); + return new CLIError(msg); +} + +export class CLIError extends Error { + name = "CLI Error"; +} + +export class ConfigError extends Error { + name = "Config Error"; } diff --git a/packages/cli/src/utils/get-config.ts b/packages/cli/src/utils/get-config.ts index 08c038ded..0f289f508 100644 --- a/packages/cli/src/utils/get-config.ts +++ b/packages/cli/src/utils/get-config.ts @@ -2,9 +2,10 @@ import fs from "node:fs"; import path from "node:path"; import color from "chalk"; import * as v from "valibot"; +import { ConfigError, error } from "./errors.js"; import { getTsconfig } from "get-tsconfig"; import { resolveImport } from "./resolve-imports.js"; -import { syncSvelteKit } from "./sync-sveltekit.js"; +import { syncSvelteKit } from "./sveltekit.js"; export const DEFAULT_STYLE = "default"; export const DEFAULT_COMPONENTS = "$lib/components"; @@ -66,17 +67,18 @@ export async function resolveConfigPaths(cwd: string, config: RawConfig) { const pathAliases = getTSConfig(cwd, tsconfigType); if (pathAliases === null) { - throw new Error( + throw error( `Missing ${highlight("paths")} field in your ${highlight(tsconfigType)} for path aliases. See: ${color.underline("https://www.shadcn-svelte.com/docs/installation/manual#configure-path-aliases")}` ); } const utilsPath = resolveImport(config.aliases.utils, pathAliases); const componentsPath = resolveImport(config.aliases.components, pathAliases); - const aliasError = (type: string, alias: string) => - new Error( - `[components.json]: Invalid ${highlight(type)} import alias: ${highlight(alias)}. Import aliases ${color.underline("must use")} existing path aliases defined in your ${highlight(tsconfigType)}. See: ${color.underline("https://www.shadcn-svelte.com/docs/installation/manual#configure-path-aliases")}.` + new ConfigError( + `Invalid import alias found: (${highlight(`"${type}": "${alias}"`)}) in ${highlight("components.json")}. + - Import aliases ${color.underline("must use")} existing path aliases defined in your ${highlight(tsconfigType)} (e.g. "${type}": "$lib/${type}"). + - See: ${color.underline("https://www.shadcn-svelte.com/docs/installation/manual#configure-path-aliases")}.` ); if (utilsPath === undefined) throw aliasError("utils", config.aliases.utils); @@ -96,7 +98,7 @@ export async function resolveConfigPaths(cwd: string, config: RawConfig) { export function getTSConfig(cwd: string, tsconfigName: "tsconfig.json" | "jsconfig.json") { const parsedConfig = getTsconfig(path.resolve(cwd, "package.json"), tsconfigName); if (parsedConfig === null) { - throw new Error( + throw error( `Failed to find ${highlight(tsconfigName)}. See: ${color.underline("https://www.shadcn-svelte.com/docs/installation#opt-out-of-typescript")}` ); } @@ -121,7 +123,7 @@ export async function getRawConfig(cwd: string): Promise { const config = JSON.parse(configResult); return v.parse(rawConfigSchema, config); - } catch (error) { - throw new Error(`[components.json]: Invalid configuration found in ${highlight(configPath)}.`); + } catch (err) { + throw new ConfigError(`Invalid configuration found in ${highlight(configPath)}.`); } } diff --git a/packages/cli/src/utils/get-package-info.ts b/packages/cli/src/utils/get-package-info.ts index 71255ab9b..a641d4af8 100644 --- a/packages/cli/src/utils/get-package-info.ts +++ b/packages/cli/src/utils/get-package-info.ts @@ -1,5 +1,3 @@ -// Credit to @shadcn for the original code. It has been slightly modified to fit the needs of this project. - import path from "node:path"; import fs from "node:fs"; import { fileURLToPath } from "node:url"; @@ -7,7 +5,6 @@ import type { PackageJson } from "type-fest"; export function getPackageInfo() { const packageJsonPath = getPackageFilePath("../package.json"); - return readJSONSync(packageJsonPath) as PackageJson; } @@ -26,10 +23,3 @@ function readJSONSync(path: string): unknown { const content = fs.readFileSync(path, { encoding: "utf8" }); return JSON.parse(content); } - -// we'll load the user's package.json and check if @sveltejs/kit is a dependency -export function isUsingSvelteKit(cwd: string): boolean { - const packageJSON = loadProjectPackageInfo(cwd); - const deps = { ...packageJSON.devDependencies, ...packageJSON.dependencies }; - return Object.keys(deps).some((dep) => dep === "@sveltejs/kit"); -} diff --git a/packages/cli/src/utils/registry/index.ts b/packages/cli/src/utils/registry/index.ts index abd3b81bd..11b39430c 100644 --- a/packages/cli/src/utils/registry/index.ts +++ b/packages/cli/src/utils/registry/index.ts @@ -3,6 +3,7 @@ import * as v from "valibot"; import fetch from "node-fetch"; import { HttpsProxyAgent } from "https-proxy-agent"; import * as schemas from "./schema.js"; +import { error } from "../errors.js"; import { getEnvProxy } from "../get-env-proxy.js"; import type { Config } from "../get-config.js"; @@ -15,8 +16,8 @@ export async function getRegistryIndex() { const [result] = await fetchRegistry(["index.json"]); return v.parse(schemas.registryIndexSchema, result); - } catch (error) { - throw new Error(`Failed to fetch components from registry.`); + } catch (e) { + throw error(`Failed to fetch components from registry.`); } } @@ -25,8 +26,8 @@ export async function getRegistryStyles() { const [result] = await fetchRegistry(["styles/index.json"]); return v.parse(schemas.stylesSchema, result); - } catch (error) { - throw new Error(`Failed to fetch styles from registry.`); + } catch (e) { + throw error(`Failed to fetch styles from registry.`); } } @@ -60,8 +61,8 @@ export async function getRegistryBaseColor(baseColor: string) { const [result] = await fetchRegistry([`colors/${baseColor}.json`]); return v.parse(schemas.registryBaseColorSchema, result); - } catch (error) { - throw new Error(`Failed to fetch base color from registry.`); + } catch (e) { + throw error(`Failed to fetch base color from registry.`); } } @@ -96,8 +97,8 @@ export async function fetchTree(config: Config, tree: RegistryIndex) { const result = await fetchRegistry(paths); return v.parse(schemas.registryWithContentSchema, result); - } catch (error) { - throw new Error(`Failed to fetch tree from registry.`); + } catch (e) { + throw error(`Failed to fetch tree from registry.`); } } @@ -133,8 +134,7 @@ async function fetchRegistry(paths: string[]) { ); return results; - } catch (error) { - console.error(error); - throw new Error(`Failed to fetch registry from ${baseUrl}.`); + } catch (e) { + throw error(`Failed to fetch registry from ${baseUrl}.`); } } diff --git a/packages/cli/src/utils/sync-sveltekit.ts b/packages/cli/src/utils/sveltekit.ts similarity index 54% rename from packages/cli/src/utils/sync-sveltekit.ts rename to packages/cli/src/utils/sveltekit.ts index 5289862df..8de7dafe2 100644 --- a/packages/cli/src/utils/sync-sveltekit.ts +++ b/packages/cli/src/utils/sveltekit.ts @@ -1,6 +1,6 @@ import { execa } from "execa"; import { getPackageManager } from "./get-package-manager.js"; -import { isUsingSvelteKit } from "./get-package-info.js"; +import { loadProjectPackageInfo } from "./get-package-info.js"; // if it's a SvelteKit project, run sync so that the aliases are always up to date export async function syncSvelteKit(cwd: string) { @@ -12,3 +12,12 @@ export async function syncSvelteKit(cwd: string) { }); } } + +/** + * Loads the user's `package.json` and check if `@sveltejs/kit` is a dependency. + */ +export function isUsingSvelteKit(cwd: string): boolean { + const packageJSON = loadProjectPackageInfo(cwd); + const deps = { ...packageJSON.devDependencies, ...packageJSON.dependencies }; + return deps["@sveltejs/kit"] !== undefined; +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 9f1134344..f21a1360a 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -4,8 +4,8 @@ "forceConsistentCasingInFileNames": true, "isolatedModules": true, "moduleResolution": "Bundler", - "module": "ES2020", - "target": "ES2020", + "module": "ES2022", + "target": "ES2022", "skipLibCheck": true, "strict": true, "noUncheckedIndexedAccess": true diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 42e3806dd..1245b0c50 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -4,7 +4,6 @@ export default defineConfig({ clean: true, entry: ["src/index.ts"], format: ["esm"], - sourcemap: true, - target: "esnext", + target: "es2022", outDir: "dist", }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 828bcdd81..958edda5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -414,6 +414,9 @@ importers: get-tsconfig: specifier: ^4.7.3 version: 4.7.3 + ignore: + specifier: ^5.3.1 + version: 5.3.1 prettier: specifier: ^3.0.0 version: 3.2.5