diff --git a/apps/v4/content/docs/06.cli.md b/apps/v4/content/docs/06.cli.md index 85a58fd44..9202ac95e 100644 --- a/apps/v4/content/docs/06.cli.md +++ b/apps/v4/content/docs/06.cli.md @@ -90,6 +90,34 @@ Options: --- +## apply + +Use the `apply` command to apply a preset to an existing project. + +```bash +npx shadcn-vue@latest apply --preset nova +``` + +**Options** + +```bash +Usage: shadcn-vue apply [options] [preset] + +apply a preset to an existing project + +Arguments: + preset the preset to apply + +Options: + --preset preset configuration to apply + -y, --yes skip confirmation prompt. (default: false) + -c, --cwd the working directory. defaults to the current directory. + -s, --silent mute output. (default: false) + -h, --help display help for command +``` + +--- + ## view Use the `view` command to view items from the registry before installing them. diff --git a/apps/v4/registry/config.ts b/apps/v4/registry/config.ts index d75ee4c10..5def02765 100644 --- a/apps/v4/registry/config.ts +++ b/apps/v4/registry/config.ts @@ -329,6 +329,12 @@ export function buildRegistryBase( const baseItem = getBase(config.base) const iconLibraryItem = getIconLibrary(config.iconLibrary) + // Mirrors shadcn-ui: if a user picked the same font for heading and body, + // collapse to "inherit" so we don't emit a redundant --font-heading var + // that's just an alias of --font-sans. + const normalizedFontHeading + = config.fontHeading === config.font ? "inherit" : config.fontHeading + if (!baseItem || !iconLibraryItem) { throw new Error( `Base "${config.base}" or icon library "${config.iconLibrary}" not found`, @@ -339,7 +345,7 @@ export function buildRegistryBase( // Build dependencies. const dependencies = [ - `shadcn@${SHADCN_VERSION}`, + `shadcn-vue@${SHADCN_VERSION}`, "class-variance-authority", "tw-animate-css", ...(baseItem.dependencies ?? []), @@ -356,6 +362,10 @@ export function buildRegistryBase( // that actually applies the font — the CLI's addFontImportPlugin only // handles the Google Fonts @import url(...) line. const fontItem = fonts.find(f => f.name === `font-${config.font}`) + const fontHeadingItem + = normalizedFontHeading !== "inherit" + ? fonts.find(f => f.name === `font-${normalizedFontHeading}`) + : undefined const themeVars: Record = { ...(registryTheme.cssVars?.theme as Record | undefined), @@ -377,6 +387,19 @@ export function buildRegistryBase( bodyRules[`@apply ${applyClass}`] = {} } + // Emit --font-heading so the Tailwind v4 `font-heading` utility is wired + // up. When fontHeading is "inherit" (default) we alias it to the body + // font's CSS variable; otherwise we resolve the heading font's family + // from the web registry and emit it literally. The heading font's Google + // Fonts @import is pulled in CLI-side by addFontImportPlugin (see + // add-components.ts) via `config.fontHeading`. + if (normalizedFontHeading === "inherit") { + themeVars["--font-heading"] = `var(${fontItem?.font.variable ?? "--font-sans"})` + } + else if (fontHeadingItem) { + themeVars["--font-heading"] = fontHeadingItem.font.family + } + return { name: `${config.base}-${config.style}`, extends: "none", @@ -385,6 +408,10 @@ export function buildRegistryBase( style: `${config.base}-${config.style}`, iconLibrary: iconLibraryItem.name, font: config.font, + // Only persist fontHeading when it's a real override, so projects + // with the default ("inherit") don't gain a new components.json field. + ...(normalizedFontHeading !== "inherit" + && { fontHeading: normalizedFontHeading }), rtl: config.rtl ?? false, menuColor: config.menuColor, menuAccent: config.menuAccent, @@ -400,7 +427,7 @@ export function buildRegistryBase( }, css: { "@import \"tw-animate-css\"": {}, - "@import \"shadcn/tailwind.css\"": {}, + "@import \"shadcn-vue/tailwind.css\"": {}, "@layer base": { "*": { "@apply border-border outline-ring/50": {} }, "body": bodyRules, diff --git a/packages/cli/src/commands/apply.test.ts b/packages/cli/src/commands/apply.test.ts new file mode 100644 index 000000000..5f2e232bf --- /dev/null +++ b/packages/cli/src/commands/apply.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from 'vitest' +import { encodePreset } from '@/src/preset/preset' +import { + getBase, + getInitCommand, + resolveApplyInitUrl, + resolveApplyPreset, +} from './apply' + +vi.mock('@/src/utils/handle-error', () => ({ + handleError: vi.fn(), +})) + +vi.mock('@/src/utils/logger', () => ({ + logger: { + error: vi.fn(), + warn: vi.fn(), + log: vi.fn(), + info: vi.fn(), + break: vi.fn(), + }, +})) + +describe('resolveApplyPreset', () => { + const baseOptions = { + cwd: '/tmp', + yes: false, + silent: false, + } + + it('returns the positional preset when only a positional is provided', () => { + expect( + resolveApplyPreset({ ...baseOptions, positionalPreset: 'nova' }), + ).toBe('nova') + }) + + it('returns the flag preset when only --preset is provided', () => { + expect(resolveApplyPreset({ ...baseOptions, preset: 'luma' })).toBe('luma') + }) + + it('returns the value when positional and flag agree', () => { + expect( + resolveApplyPreset({ + ...baseOptions, + positionalPreset: 'vega', + preset: 'vega', + }), + ).toBe('vega') + }) + + it('trims surrounding whitespace from preset values', () => { + expect( + resolveApplyPreset({ ...baseOptions, positionalPreset: ' mira ' }), + ).toBe('mira') + }) + + it('returns undefined when no preset is provided', () => { + expect(resolveApplyPreset(baseOptions)).toBeUndefined() + }) + + it('exits when positional and flag presets disagree', () => { + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(((_code?: number) => { + throw new Error('process.exit called') + }) as never) + + try { + expect(() => + resolveApplyPreset({ + ...baseOptions, + positionalPreset: 'vega', + preset: 'nova', + }), + ).toThrow('process.exit called') + + expect(exitSpy).toHaveBeenCalledWith(1) + } + finally { + // Guard with `finally` so a failed assertion above can't leak the + // spy into neighbouring tests. Vitest is not configured with + // `restoreMocks: true`, so this cleanup has to be explicit. + exitSpy.mockRestore() + } + }) +}) + +describe('getBase', () => { + it('extracts the base segment from a composed style id', () => { + expect(getBase('reka-vega')).toBe('reka') + expect(getBase('reka-nova')).toBe('reka') + }) + + it('falls back to the default base for unknown / empty styles', () => { + expect(getBase(undefined)).toBe('reka') + expect(getBase('')).toBe('reka') + expect(getBase('mystery-style')).toBe('reka') + }) +}) + +describe('getInitCommand', () => { + it('returns the bare init command when no preset is provided', () => { + expect(getInitCommand()).toBe('shadcn-vue init') + }) + + it('appends a simple preset name without quoting', () => { + expect(getInitCommand('nova')).toBe('shadcn-vue init --preset nova') + }) + + it('quotes preset values that contain shell-unsafe characters', () => { + expect( + getInitCommand('https://example.com/init?style=nova&base=reka'), + ).toBe( + 'shadcn-vue init --preset "https://example.com/init?style=nova&base=reka"', + ) + }) +}) + +describe('resolveApplyInitUrl', () => { + it('builds an init URL for a named preset and forces base + rtl', () => { + const url = resolveApplyInitUrl('nova', { base: 'reka', rtl: true }) + expect(url).not.toBeNull() + + const parsed = new URL(url!) + expect(parsed.pathname).toBe('/init') + expect(parsed.searchParams.get('base')).toBe('reka') + expect(parsed.searchParams.get('style')).toBe('nova') + expect(parsed.searchParams.get('iconLibrary')).toBe('lucide') + expect(parsed.searchParams.get('font')).toBe('geist-sans') + expect(parsed.searchParams.get('baseColor')).toBe('neutral') + expect(parsed.searchParams.get('rtl')).toBe('true') + }) + + it('always overrides the preset base with the project current base', () => { + // Even if a future named preset shipped with a different base, the + // current project base must win — applying a preset never silently + // switches the user's component library. + const url = resolveApplyInitUrl('vega', { base: 'reka', rtl: false }) + expect(url).not.toBeNull() + const parsed = new URL(url!) + expect(parsed.searchParams.get('base')).toBe('reka') + expect(parsed.searchParams.get('rtl')).toBe('false') + }) + + it('builds an init URL for an encoded preset', () => { + const code = encodePreset({ + style: 'nova', + baseColor: 'zinc', + theme: 'zinc', + font: 'inter', + iconLibrary: 'lucide', + }) + const url = resolveApplyInitUrl(code, { base: 'reka', rtl: false }) + expect(url).not.toBeNull() + + const parsed = new URL(url!) + expect(parsed.searchParams.get('style')).toBe('nova') + expect(parsed.searchParams.get('baseColor')).toBe('zinc') + expect(parsed.searchParams.get('theme')).toBe('zinc') + expect(parsed.searchParams.get('font')).toBe('inter') + expect(parsed.searchParams.get('iconLibrary')).toBe('lucide') + // currentBase carried through. + expect(parsed.searchParams.get('base')).toBe('reka') + // Original preset code round-tripped for server-side compat fixups. + expect(parsed.searchParams.get('preset')).toBe(code) + }) + + it('passes a remote URL through with base + rtl overrides applied', () => { + const remote + = 'https://shadcn-vue.com/init?base=other&style=mira&font=figtree' + const url = resolveApplyInitUrl(remote, { base: 'reka', rtl: true }) + expect(url).not.toBeNull() + + const parsed = new URL(url!) + expect(parsed.searchParams.get('base')).toBe('reka') + expect(parsed.searchParams.get('rtl')).toBe('true') + // Non-overridden params come through unchanged. + expect(parsed.searchParams.get('style')).toBe('mira') + expect(parsed.searchParams.get('font')).toBe('figtree') + // First-party /init URLs are tracked. + expect(parsed.searchParams.get('track')).toBe('1') + }) + + it('always writes rtl=false on a remote URL when the project is LTR', () => { + const remote = 'https://shadcn-vue.com/init?base=reka&style=nova&rtl=true' + const url = resolveApplyInitUrl(remote, { base: 'reka', rtl: false }) + const parsed = new URL(url!) + expect(parsed.searchParams.get('rtl')).toBe('false') + }) + + it('does not add track=1 to third-party URLs', () => { + const remote = 'https://example.com/init?style=nova' + const url = resolveApplyInitUrl(remote, { base: 'reka', rtl: false }) + const parsed = new URL(url!) + expect(parsed.searchParams.has('track')).toBe(false) + }) + + it('returns null for an unknown named preset', () => { + expect( + resolveApplyInitUrl('not-a-real-preset', { base: 'reka', rtl: false }), + ).toBeNull() + }) +}) diff --git a/packages/cli/src/commands/apply.ts b/packages/cli/src/commands/apply.ts new file mode 100644 index 000000000..2856f757e --- /dev/null +++ b/packages/cli/src/commands/apply.ts @@ -0,0 +1,335 @@ +import type { registryConfigSchema } from '@/src/registry/schema' +import { Command } from 'commander' +import path from 'pathe' +import prompts from 'prompts' +import { z } from 'zod' +import { runInit } from '@/src/commands/init' +import { preFlightApply } from '@/src/preflights/preflight-apply' +import { decodePreset, isPresetCode } from '@/src/preset/preset' +import { + DEFAULT_PRESETS, + resolveInitUrl, + resolveRegistryBaseConfig, +} from '@/src/preset/presets' +import { BASES, SHADCN_VUE_URL } from '@/src/registry/constants' +import { clearRegistryContext } from '@/src/registry/context' +import { isUrl } from '@/src/registry/utils' +import { loadEnvFiles } from '@/src/utils/env-loader' +import * as ERRORS from '@/src/utils/errors' +import { withFileCopyBackup } from '@/src/utils/file-helper' +import { getProjectComponents } 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' + +export const applyOptionsSchema = z.object({ + cwd: z.string(), + positionalPreset: z.string().optional(), + preset: z.string().optional(), + yes: z.boolean(), + silent: z.boolean(), +}) + +const PRESET_NAMES = Object.keys(DEFAULT_PRESETS) + +export const apply = new Command() + .name('apply') + .description('apply a preset to an existing project') + .argument('[preset]', 'the preset to apply') + .option('--preset ', 'preset configuration to apply') + .option('-y, --yes', 'skip confirmation prompt.', false) + .option( + '-c, --cwd ', + 'the working directory. defaults to the current directory.', + process.cwd(), + ) + .option('-s, --silent', 'mute output.', false) + .action(async (positionalPreset, opts) => { + try { + const options = applyOptionsSchema.parse({ + ...opts, + cwd: path.resolve(opts.cwd), + positionalPreset, + }) + + const presetValue = resolveApplyPreset(options) + + const preflight = await preFlightApply(options) + + if (preflight.errors[ERRORS.MISSING_DIR_OR_EMPTY_PROJECT]) { + logger.break() + logger.error( + `The ${highlighter.info( + 'apply', + )} command only works in an existing project.`, + ) + logger.error(`Run ${highlighter.info(getInitCommand(presetValue))} first.`) + logger.break() + process.exit(1) + } + + if (preflight.errors[ERRORS.MISSING_CONFIG]) { + logger.break() + logger.error( + `No ${highlighter.info('components.json')} found at ${highlighter.info( + options.cwd, + )}.`, + ) + logger.error(`Run ${highlighter.info(getInitCommand(presetValue))} first.`) + logger.break() + process.exit(1) + } + + const existingConfig = preflight.config + if (!existingConfig) { + process.exit(1) + } + + if (!presetValue) { + logger.break() + logger.error( + `Please provide a preset to apply.\nAvailable presets: ${PRESET_NAMES.join( + ', ', + )}\nYou can also pass an encoded preset code or a preset URL.`, + ) + logger.break() + process.exit(1) + } + + // Reject obviously invalid preset strings early. + if ( + !isUrl(presetValue) + && !isPresetCode(presetValue) + && !(presetValue in DEFAULT_PRESETS) + ) { + logger.break() + logger.error( + `Invalid preset: ${highlighter.info( + presetValue, + )}.\nAvailable presets: ${PRESET_NAMES.join(', ')}`, + ) + logger.break() + process.exit(1) + } + + const currentBase = getBase(existingConfig.style) + const currentRtl = existingConfig.rtl ?? false + + const initUrl = resolveApplyInitUrl(presetValue, { + base: currentBase, + rtl: currentRtl, + }) + if (!initUrl) { + logger.break() + logger.error( + `Invalid preset: ${highlighter.info( + presetValue, + )}.\nAvailable presets: ${PRESET_NAMES.join(', ')}`, + ) + logger.break() + process.exit(1) + } + + const reinstallComponents = await getProjectComponents(options.cwd) + + if (!options.yes) { + logger.break() + logger.warn( + highlighter.warn( + `Applying a new preset will overwrite existing UI components, fonts, and CSS variables.`, + ), + ) + logger.warn( + `Commit or stash your changes before continuing so you can easily go back.`, + ) + logger.break() + logger.log(' The following components will be re-installed:') + if (reinstallComponents.length) { + for (let i = 0; i < reinstallComponents.length; i += 8) { + logger.log(` - ${reinstallComponents.slice(i, i + 8).join(', ')}`) + } + } + else { + logger.log(' - No installed UI components were detected.') + } + logger.break() + + const { proceed } = await prompts({ + type: 'confirm', + name: 'proceed', + message: 'Would you like to continue?', + initial: false, + }) + + if (!proceed) { + logger.break() + process.exit(1) + } + } + + await loadEnvFiles(options.cwd) + + // Wrap runInit in a backup/restore primitive. runInit funnels errors + // through addComponents → handleError → process.exit, which bypasses + // any try/catch here, so we rely on withFileCopyBackup's exit listener + // to restore components.json on failure. + await withFileCopyBackup( + path.resolve(options.cwd, 'components.json'), + async () => { + const { + registryBaseConfig, + installStyleIndex, + url: cleanInitUrl, + } = await resolveRegistryBaseConfig(initUrl, options.cwd, { + registries: existingConfig.registries as + | z.infer + | undefined, + }) + + await runInit({ + cwd: options.cwd, + preset: presetValue, + base: currentBase, + registryBaseConfig, + installStyleIndex, + components: [cleanInitUrl, ...reinstallComponents], + yes: true, + force: false, + defaults: false, + silent: options.silent, + isNewProject: false, + cssVariables: existingConfig.tailwind?.cssVariables ?? true, + baseStyle: installStyleIndex !== false, + rtl: currentRtl, + reinstall: true, + skipPreflight: true, + }) + }, + { + suffix: '.apply.bak', + onBackupFailure: () => { + logger.error( + `Could not back up ${highlighter.info( + 'components.json', + )}. Aborting.`, + ) + }, + }, + ) + + logger.break() + logger.log(`${highlighter.success('Success!')} Preset applied.`) + logger.break() + } + catch (error) { + logger.break() + handleError(error) + } + finally { + clearRegistryContext() + } + }) + +export function resolveApplyPreset(options: z.infer) { + const positionalPreset = options.positionalPreset?.trim() + const flagPreset = options.preset?.trim() + + if (positionalPreset && flagPreset && positionalPreset !== flagPreset) { + logger.error( + `Received two different preset values. Use either the positional preset or ${highlighter.info( + '--preset', + )}, or pass the same value to both.`, + ) + logger.break() + process.exit(1) + } + + return flagPreset ?? positionalPreset +} + +function quoteShellArg(value: string) { + return /[^\w./:-]/.test(value) ? JSON.stringify(value) : value +} + +export function getInitCommand(preset?: string) { + if (!preset) { + return 'shadcn-vue init' + } + + return `shadcn-vue init --preset ${quoteShellArg(preset)}` +} + +/** + * Returns the component-library base for a composed style identifier + * (e.g. `reka-vega` → `reka`). Falls back to the first registered base + * (`reka`) when the style is empty or unrecognised. + * + * Mirrors shadcn-ui's `getBase` helper but adapted to shadcn-vue's + * `${base}-${visualStyle}` style id convention. + */ +export function getBase(style: string | undefined): string { + const fallback = BASES[0]?.name ?? 'reka' + if (!style) { + return fallback + } + + const [maybeBase] = style.split('-') + if (maybeBase && BASES.find(b => b.name === maybeBase)) { + return maybeBase + } + + return fallback +} + +/** + * Resolves a preset input (named, encoded, or URL) into the final `/init` + * registry URL that `resolveRegistryBaseConfig` will fetch. The project's + * current `base` and `rtl` are forced onto the resulting URL so applying a + * preset never silently switches the user's component library or RTL setting. + * + * This is the shadcn-vue equivalent of shadcn-ui's `resolveApplyInitUrl`. + */ +export function resolveApplyInitUrl( + presetArg: string, + options: { base: string, rtl: boolean, template?: string }, +): string | null { + if (isUrl(presetArg)) { + const url = new URL(presetArg) + + // Record an init run for first-party shadcn-vue /init URLs only. + if ( + url.pathname === '/init' + && presetArg.startsWith(SHADCN_VUE_URL) + ) { + url.searchParams.set('track', '1') + } + + url.searchParams.set('base', options.base) + url.searchParams.set('rtl', String(options.rtl)) + + return url.toString() + } + + if (isPresetCode(presetArg)) { + const decoded = decodePreset(presetArg) + if (!decoded) { + logger.error(`Invalid preset code: ${highlighter.info(presetArg)}`) + logger.break() + process.exit(1) + } + return resolveInitUrl( + { ...decoded, base: options.base, rtl: options.rtl }, + { template: options.template, preset: presetArg }, + ) + } + + const preset = DEFAULT_PRESETS[presetArg as keyof typeof DEFAULT_PRESETS] + if (!preset) { + return null + } + + return resolveInitUrl( + { ...preset, base: options.base, rtl: options.rtl }, + { template: options.template }, + ) +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 7ae0a3dd9..fd9d21f1e 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -807,7 +807,11 @@ async function promptForMinimalConfig( let style = opts.style ?? defaultConfig.style let iconLibrary = opts.iconLibrary ?? defaultConfig.iconLibrary ?? 'lucide' let font = opts.font ?? defaultConfig.font ?? 'inter' - let baseColor = opts.baseColor + let baseColor = opts.baseColor ?? defaultConfig.tailwind.baseColor + // Preserve the project's existing cssVariables unless the user explicitly + // overrode it on the command line. Since `--css-variables` defaults to + // `true` in Commander, pulling from `opts` unconditionally would flip an + // existing `tailwind.cssVariables: false` back to `true` on every run. let cssVariables = defaultConfig.tailwind.cssVariables if (opts.preset === undefined && !opts.defaults) { @@ -891,6 +895,8 @@ async function promptForMinimalConfig( $schema: defaultConfig?.$schema, style: composeStyleId(base, style), font, + ...(defaultConfig.fontHeading + && { fontHeading: defaultConfig.fontHeading }), iconLibrary, rtl: opts.rtl ?? defaultConfig.rtl ?? false, tailwind: { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e3b2a691b..447501fbe 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command } from 'commander' import { add } from '@/src/commands/add' +import { apply } from '@/src/commands/apply' import { build } from '@/src/commands/build' import { diff } from '@/src/commands/diff' import { docs } from '@/src/commands/docs' @@ -29,6 +30,7 @@ async function main() { program .addCommand(init) .addCommand(add) + .addCommand(apply) .addCommand(diff) .addCommand(docs) .addCommand(view) diff --git a/packages/cli/src/preflights/preflight-apply.ts b/packages/cli/src/preflights/preflight-apply.ts new file mode 100644 index 000000000..88c839ce7 --- /dev/null +++ b/packages/cli/src/preflights/preflight-apply.ts @@ -0,0 +1,59 @@ +import fs from 'fs-extra' +import path from 'pathe' +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' + +export async function preFlightApply(options: { cwd: string }) { + const errors: Record = {} + + 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, + } + } + + 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) + if (!config) { + reportInvalidConfig(options.cwd) + } + + return { + errors, + config, + } + } + catch { + reportInvalidConfig(options.cwd) + } +} + +function reportInvalidConfig(cwd: string): never { + logger.break() + logger.error( + `An invalid or empty ${highlighter.info( + 'components.json', + )} file was found at ${highlighter.info( + cwd, + )}.\nBefore you can apply a preset, you must create a valid ${highlighter.info( + 'components.json', + )} file by running the ${highlighter.info('init')} command.`, + ) + logger.break() + process.exit(1) +} diff --git a/packages/cli/src/preset/presets.ts b/packages/cli/src/preset/presets.ts index 476388308..087bd343e 100644 --- a/packages/cli/src/preset/presets.ts +++ b/packages/cli/src/preset/presets.ts @@ -325,7 +325,26 @@ export async function resolveRegistryBaseConfig( }.`, ) } - const registryBaseConfig = item.config + const registryBaseConfig = item.config as Record + + // Overlay URL-derived params onto the returned config. The server-side + // `buildRegistryBase` may not emit every preset field (older deployments + // don't know about `fontHeading` for example), but the CLI built the init + // URL locally with every field it cares about. Filling missing fields from + // the URL makes the preset flow work end-to-end without waiting on a + // server redeploy. + if (isUrl(initUrl)) { + const params = new URL(initUrl).searchParams + const fontHeading = params.get('fontHeading') + if ( + fontHeading + && fontHeading !== 'inherit' + && fontHeading !== params.get('font') + && !registryBaseConfig.fontHeading + ) { + registryBaseConfig.fontHeading = fontHeading + } + } // Strip the track param so subsequent fetches don't re-trigger tracking. let cleanUrl = initUrl diff --git a/packages/cli/src/registry/schema.ts b/packages/cli/src/registry/schema.ts index c65db1e30..5c83444b0 100644 --- a/packages/cli/src/registry/schema.ts +++ b/packages/cli/src/registry/schema.ts @@ -30,6 +30,7 @@ export const rawConfigSchema = z $schema: z.string().optional(), style: z.string(), font: z.string().optional(), + fontHeading: z.string().optional(), typescript: z.coerce.boolean().default(true), tailwind: z.object({ config: z.string().optional(), diff --git a/packages/cli/src/utils/add-components.ts b/packages/cli/src/utils/add-components.ts index 8be884651..858393962 100644 --- a/packages/cli/src/utils/add-components.ts +++ b/packages/cli/src/utils/add-components.ts @@ -1,5 +1,6 @@ import type { configSchema, + registryItemCssVarsSchema, registryItemFileSchema, workspaceConfigSchema, } from '@/src/schema' @@ -12,7 +13,7 @@ import { resolveRegistryTree } from '@/src/registry/resolver' import { registryItemSchema, } from '@/src/schema' -import { getFontImport } from '@/src/utils/fonts' +import { getFont, getFontImport, getFontVariable } from '@/src/utils/fonts' import { findCommonRoot, findPackageRoot, @@ -109,15 +110,16 @@ async function addProjectComponents( }) const overwriteCssVars = await shouldOverwriteCssVars(components, config) - const fontImport = config.font ? getFontImport(config.font) : undefined - await updateCssVars(tree.cssVars, config, { + const fontImports = resolveFontImports(config) + const cssVarsWithFontHeading = overlayFontHeadingVar(tree.cssVars, config) + await updateCssVars(cssVarsWithFontHeading, config, { cleanupDefaultNextStyles: options.isNewProject, silent: options.silent, tailwindVersion, tailwindConfig: tree.tailwind?.config, overwriteCssVars, initIndex: options.baseStyle, - fontImport, + fontImports, }) // Add CSS updater @@ -214,13 +216,17 @@ async function addWorkspaceComponents( // 2. Update css vars. if (tree.cssVars) { const overwriteCssVars = await shouldOverwriteCssVars(components, config) - const fontImport = mainTargetConfig.font ? getFontImport(mainTargetConfig.font) : undefined - await updateCssVars(tree.cssVars, mainTargetConfig, { + const fontImports = resolveFontImports(mainTargetConfig) + const cssVarsWithFontHeading = overlayFontHeadingVar( + tree.cssVars, + mainTargetConfig, + ) + await updateCssVars(cssVarsWithFontHeading, mainTargetConfig, { silent: true, tailwindVersion, tailwindConfig: tree.tailwind?.config, overwriteCssVars, - fontImport, + fontImports, }) filesUpdated.push( path.relative(workspaceRoot, mainTargetConfig.resolvedPaths.tailwindCss), @@ -371,6 +377,93 @@ async function addWorkspaceComponents( } } +/** + * Collects the Google Fonts `@import` strings that should be present in the + * project's CSS file for the active config. Body font + (optional) heading + * font, de-duplicated. Empty fonts are skipped. + */ +function resolveFontImports(config: Config): string[] { + const imports: string[] = [] + const push = (name: string | undefined) => { + if (!name) { + return + } + const imp = getFontImport(name) + if (imp && !imports.includes(imp)) { + imports.push(imp) + } + } + push(config.font) + if ( + config.fontHeading + && config.fontHeading !== 'inherit' + && config.fontHeading !== config.font + ) { + push(config.fontHeading) + } + return imports +} + +/** + * Builds the CSS `@theme inline` value for `--font-heading` from the active + * config, mirroring the server-side `buildRegistryBase` output so older + * deployments of shadcn-vue.com (that don't emit `--font-heading` yet) still + * produce a working theme var on the CLI side. Once the registry route is + * redeployed this becomes redundant — the server's authoritative value will + * overwrite it via `updateCssVarsPluginV4` with `overwriteCssVars: true`. + * + * Returns `undefined` when there's no font configured at all. + */ +function resolveFontHeadingVar(config: Config): string | undefined { + if (!config.font) { + return undefined + } + + const fontHeading = config.fontHeading + // `inherit` / unset / same-as-body all alias to the body font's CSS var so + // `font-heading` utility always resolves to something. + if ( + !fontHeading + || fontHeading === 'inherit' + || fontHeading === config.font + ) { + const bodyVar = getFontVariable(config.font) + return `var(${bodyVar})` + } + + const headingFont = getFont(fontHeading) + if (!headingFont) { + return undefined + } + + const fallback + = headingFont.variable === '--font-mono' ? 'monospace' : 'sans-serif' + return `'${headingFont.family} Variable', ${fallback}` +} + +/** + * Merges a synthesized `--font-heading` entry into the theme vars we pass to + * `updateCssVars`. Only runs when the server response didn't already include + * one, so a redeployed server stays authoritative. + */ +function overlayFontHeadingVar( + cssVars: z.infer | undefined, + config: Config, +): z.infer | undefined { + const value = resolveFontHeadingVar(config) + if (!value) { + return cssVars + } + const theme = cssVars?.theme ?? {} + if (theme['--font-heading']) { + return cssVars + } + return { + ...(cssVars ?? {}), + theme: { ...theme, '--font-heading': value }, + } +} + async function shouldOverwriteCssVars( components: z.infer['name'][], config: z.infer, @@ -380,7 +473,10 @@ async function shouldOverwriteCssVars( return payload.some( component => - component.type === 'registry:theme' || component.type === 'registry:style', + component.type === 'registry:theme' + || component.type === 'registry:style' + || component.type === 'registry:base' + || component.type === 'registry:font', ) } diff --git a/packages/cli/src/utils/file-helper.ts b/packages/cli/src/utils/file-helper.ts index 6f41083ab..ff8f532cf 100644 --- a/packages/cli/src/utils/file-helper.ts +++ b/packages/cli/src/utils/file-helper.ts @@ -48,8 +48,77 @@ export function deleteFileBackup(filePath: string): boolean { fsExtra.unlinkSync(backupPath) return true } - catch (error) { + catch { // Best effort - don't log as this is just cleanup return false } } + +interface WithFileCopyBackupOptions { + suffix?: string + onBackupFailure?: (filePath: string) => void +} + +/** + * Runs `task` with a **copy** of `filePath` kept under ``, + * restoring it if the task throws OR if the process exits before the task + * returns. The original file stays in place for the task to read. + * + * This is the rollback primitive `apply` wraps around `runInit`. A plain + * try/catch isn't enough because `runInit → addComponents → handleError` + * funnels failures into `process.exit(1)`, which bypasses any catch block. + * The process-exit listener fires before that, so rollback still runs. + * + * Unlike shadcn-ui's `withFileBackup` (which renames), this copies so the + * original file stays readable by code that re-reads it during the task — + * shadcn-vue's `runInit` reads `components.json` from disk via + * `getProjectConfig`, so the original must stay in place. + */ +export async function withFileCopyBackup( + filePath: string, + task: () => Promise, + options: WithFileCopyBackupOptions = {}, +) { + if (!fsExtra.existsSync(filePath)) { + return task() + } + + const suffix = options.suffix ?? FILE_BACKUP_SUFFIX + const backupPath = `${filePath}${suffix}` + + try { + fsExtra.copyFileSync(filePath, backupPath) + } + catch (error) { + options.onBackupFailure?.(filePath) + throw new Error(`Could not back up ${filePath}: ${(error as Error).message}`) + } + + const restoreOnExit = () => { + if (fsExtra.existsSync(backupPath)) { + try { + fsExtra.copyFileSync(backupPath, filePath) + fsExtra.unlinkSync(backupPath) + } + catch { + // Best effort — we're already on the exit path. + } + } + } + + process.on('exit', restoreOnExit) + + try { + const result = await task() + process.removeListener('exit', restoreOnExit) + if (fsExtra.existsSync(backupPath)) { + fsExtra.unlinkSync(backupPath) + } + return result + } + catch (error) { + process.removeListener('exit', restoreOnExit) + restoreOnExit() + throw error + } +} diff --git a/packages/cli/src/utils/get-project-info.ts b/packages/cli/src/utils/get-project-info.ts index 7233b14e8..15773cfd2 100644 --- a/packages/cli/src/utils/get-project-info.ts +++ b/packages/cli/src/utils/get-project-info.ts @@ -7,6 +7,7 @@ import path from 'pathe' import { coerce } from 'semver' import { glob } from 'tinyglobby' import { z } from 'zod' +import { getShadcnRegistryIndex } from '@/src/registry/api' import { FRAMEWORKS } from '@/src/utils/frameworks' import { getConfig, resolveConfigPaths } from '@/src/utils/get-config' import { getPackageInfo } from '@/src/utils/get-package-info' @@ -350,3 +351,84 @@ export async function getProjectTailwindVersionFromConfig(config: { return projectInfo.tailwindVersion } + +/** + * Returns the list of installed UI component names for a project by scanning + * the resolved `ui` directory in `components.json` and cross-referencing + * discovered names against the shadcn-vue registry index. + * + * shadcn-vue stores components as folders (`ui/button/Button.vue`), so + * candidates come from immediate subdirectories of `ui/` that contain at + * least one renderable `.vue`/`.tsx`/`.jsx` file, plus any flat `.vue` + * single-file components. Names are then filtered against the registry to + * avoid picking up helper artefacts like `utils.ts` or `lib/` — mirrors + * shadcn-ui's `getProjectComponents`. + */ +export async function getProjectComponents(cwd: string) { + const existingConfig = await getConfig(cwd) + if (!existingConfig) { + return [] + } + + const uiDir = existingConfig.resolvedPaths.ui + if (!uiDir) { + return [] + } + + // Atomic read — avoids the TOCTOU window of an existsSync pre-check. + let entries: import('node:fs').Dirent[] + try { + entries = (await fs.readdir(uiDir, { + withFileTypes: true, + })) as import('node:fs').Dirent[] + } + catch { + return [] + } + + const candidates = new Set() + for (const entry of entries) { + if ( + entry.name.startsWith('.') + || entry.name === 'index.ts' + || entry.name === 'index.js' + ) { + continue + } + if (entry.isDirectory()) { + // Only count directories that actually contain a renderable component. + const componentDir = path.join(uiDir, entry.name) + let dirEntries: import('node:fs').Dirent[] + try { + dirEntries = (await fs.readdir(componentDir, { + withFileTypes: true, + })) as import('node:fs').Dirent[] + } + catch { + continue + } + const hasRenderable = dirEntries.some( + e => e.isFile() && /\.(?:vue|tsx|jsx)$/.test(e.name), + ) + if (hasRenderable) { + candidates.add(entry.name) + } + continue + } + if (entry.isFile() && /\.(?:vue|tsx|jsx)$/.test(entry.name)) { + candidates.add(path.basename(entry.name, path.extname(entry.name))) + } + } + + // Cross-reference against the registry index so we only return names the + // CLI actually knows how to reinstall. Matches shadcn-ui's behavior — + // without this, helper folders slip through and break the reinstall step. + const registryIndex = await getShadcnRegistryIndex() + const registryNames = new Set( + registryIndex?.map(item => item.name) ?? [], + ) + + return Array.from(candidates) + .filter(name => registryNames.has(name)) + .sort() +} diff --git a/packages/cli/src/utils/updaters/update-css-vars.ts b/packages/cli/src/utils/updaters/update-css-vars.ts index c648e0650..1b118604f 100644 --- a/packages/cli/src/utils/updaters/update-css-vars.ts +++ b/packages/cli/src/utils/updaters/update-css-vars.ts @@ -25,7 +25,7 @@ export async function updateCssVars( silent?: boolean tailwindVersion?: TailwindVersion tailwindConfig?: z.infer['config'] - fontImport?: string + fontImports?: string[] }, ) { if (!config.resolvedPaths.tailwindCss || !Object.keys(cssVars ?? {}).length) { @@ -58,7 +58,7 @@ export async function updateCssVars( tailwindConfig: options.tailwindConfig, overwriteCssVars: options.overwriteCssVars, initIndex: options.initIndex, - fontImport: options.fontImport, + fontImports: options.fontImports, }) await fs.writeFile(cssFilepath, output, 'utf8') cssVarsSpinner.succeed() @@ -74,7 +74,7 @@ export async function transformCssVars( tailwindConfig?: z.infer['config'] overwriteCssVars?: boolean initIndex?: boolean - fontImport?: string + fontImports?: string[] } = { cleanupDefaultNextStyles: false, tailwindVersion: 'v3', @@ -94,9 +94,10 @@ export async function transformCssVars( let plugins = [updateCssVarsPlugin(cssVars)] - // Add font import if provided - if (options.fontImport) { - plugins.unshift(addFontImportPlugin({ fontImport: options.fontImport })) + // Add font imports if provided + const fontImports = options.fontImports ?? [] + if (fontImports.length > 0) { + plugins.unshift(addFontImportPlugin({ fontImports })) } if (options.cleanupDefaultNextStyles) { @@ -106,9 +107,9 @@ export async function transformCssVars( if (options.tailwindVersion === 'v4') { plugins = [] - // Add font import at the very beginning if provided - if (options.fontImport) { - plugins.push(addFontImportPlugin({ fontImport: options.fontImport })) + // Add font imports at the very beginning if provided + if (fontImports.length > 0) { + plugins.push(addFontImportPlugin({ fontImports })) } // Only add tw-animate-css if project does not have tailwindcss-animate @@ -720,39 +721,95 @@ function addCustomImport({ params }: { params: string }) { } /** - * Add Google Fonts import at the top of CSS file. + * Sync Google Fonts `@import` statements at the top of the CSS file with the + * active font set (body + heading). The full set of target URLs is passed in + * each call — any existing `fonts.googleapis.com` imports not in the set are + * removed, any missing ones are added. This is replace-all semantics so that + * swapping to a new preset actually reflects the new fonts instead of + * accumulating dead imports from previous runs. */ -export function addFontImportPlugin({ fontImport }: { fontImport: string }) { +export function addFontImportPlugin({ + fontImports, +}: { + fontImports: string[] +}) { return { postcssPlugin: 'add-font-import', Once(root: Root) { - if (!fontImport) + if (!fontImports || fontImports.length === 0) { + return + } + + // Dedup + extract raw URLs from the `@import url('...')` strings. + const targetUrls: string[] = [] + for (const fontImport of fontImports) { + const match = fontImport.match(/url\(['"]?([^'"]+)['"]?\)/) + if (match && !targetUrls.includes(match[1]!)) { + targetUrls.push(match[1]!) + } + } + if (targetUrls.length === 0) { return + } - // Check if this font import already exists - const hasImport = root.nodes.some( + const existingGoogleFontsImports = root.nodes.filter( (node): node is AtRule => node.type === 'atrule' && node.name === 'import' && node.params.includes('fonts.googleapis.com'), ) - if (!hasImport) { - // Create the import node - fontImport already includes @import - // We need to extract just the URL part - const urlMatch = fontImport.match(/url\(['"]?([^'"]+)['"]?\)/) - if (urlMatch) { - const importNode = postcss.atRule({ - name: 'import', - params: `url('${urlMatch[1]}')`, - raws: { semicolon: true, before: '' }, - }) + // Drop any existing Google Fonts imports that aren't in the target + // set — those are left over from a previous preset. + const keptByUrl = new Map() + for (const node of existingGoogleFontsImports) { + const url = targetUrls.find(u => node.params.includes(u)) + if (url && !keptByUrl.has(url)) { + keptByUrl.set(url, node) + } + else { + node.remove() + } + } - // Insert at the very beginning of the file - root.prepend(importNode) - root.insertAfter(importNode, postcss.comment({ text: '---break---' })) + // Add any missing imports. Reuse the position of the first kept + // import so the top-of-file section stays stable; otherwise prepend. + const missingUrls = targetUrls.filter(url => !keptByUrl.has(url)) + if (missingUrls.length === 0) { + return + } + + const firstKept = existingGoogleFontsImports.find( + node => keptByUrl.get(targetUrls.find(u => node.params.includes(u))!) + === node, + ) + + const newNodes = missingUrls.map(url => + postcss.atRule({ + name: 'import', + params: `url('${url}')`, + raws: { semicolon: true, before: '\n' }, + }), + ) + + if (firstKept) { + // Insert the new imports right after the first kept one. + let anchor: AtRule = firstKept + for (const node of newNodes) { + root.insertAfter(anchor, node) + anchor = node } + return } + + // No existing Google Fonts imports — prepend all new ones. + for (let i = newNodes.length - 1; i >= 0; i--) { + root.prepend(newNodes[i]!) + } + root.insertAfter( + newNodes[newNodes.length - 1]!, + postcss.comment({ text: '---break---' }), + ) }, } } diff --git a/packages/cli/src/utils/updaters/update-css.ts b/packages/cli/src/utils/updaters/update-css.ts index 5620aa27f..ebc25bee7 100644 --- a/packages/cli/src/utils/updaters/update-css.ts +++ b/packages/cli/src/utils/updaters/update-css.ts @@ -487,12 +487,25 @@ function processRule(parent: Root | AtRule, selector: string, properties: any) { const atRuleMatch = prop.match(/@([a-z-]+)\s*(.*)/i) if (atRuleMatch) { const [, atRuleName, atRuleParams] = atRuleMatch - const atRule = postcss.atRule({ - name: atRuleName, - params: atRuleParams, - raws: { semicolon: true, before: '\n ' }, - }) - rule.append(atRule) + // Skip if an identical at-rule (same name + params) already + // exists. Prevents duplicate `@apply` lines accumulating each + // time `apply` / `init --force=false` re-runs, which was the + // root cause of repeated `@apply font-sans` / `@apply + // bg-background text-foreground` inside `body`. + const existingAtRule = rule.nodes?.find( + (node): node is AtRule => + node.type === 'atrule' + && node.name === atRuleName + && node.params === atRuleParams, + ) + if (!existingAtRule) { + const atRule = postcss.atRule({ + name: atRuleName, + params: atRuleParams, + raws: { semicolon: true, before: '\n ' }, + }) + rule.append(atRule) + } } } else if (typeof value === 'string') {