diff --git a/.gitignore b/.gitignore index b49484881..3b0b3496e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* -.nuxt +# .nuxt .env node_modules .DS_Store @@ -34,4 +34,4 @@ test-results/ playwright-report/ vite.config.ts.timestamp* -**/.vitepress/cache/* \ No newline at end of file +**/.vitepress/cache/* diff --git a/apps/www/.vitepress/theme/components/CodeSandbox.vue b/apps/www/.vitepress/theme/components/CodeSandbox.vue index 89f20b516..363a3f018 100644 --- a/apps/www/.vitepress/theme/components/CodeSandbox.vue +++ b/apps/www/.vitepress/theme/components/CodeSandbox.vue @@ -1,5 +1,5 @@ + + diff --git a/packages/cli/test/fixtures/frameworks/vite/package.json b/packages/cli/test/fixtures/frameworks/vite/package.json new file mode 100644 index 000000000..bd54a9b3b --- /dev/null +++ b/packages/cli/test/fixtures/frameworks/vite/package.json @@ -0,0 +1,27 @@ +{ + "name": "test-cli-vite", + "type": "module", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "run-p type-check \"build-only {@}\" --", + "preview": "vite preview", + "build-only": "vite build", + "type-check": "vue-tsc --build --force" + }, + "dependencies": { + "vue": "^3.5.12" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.0", + "@types/node": "^22.9.0", + "@vitejs/plugin-vue": "^5.1.4", + "@vue/tsconfig": "^0.5.1", + "npm-run-all2": "^7.0.1", + "typescript": "~5.6.3", + "vite": "^5.4.10", + "vite-plugin-vue-devtools": "^7.5.4", + "vue-tsc": "^2.1.10" + } +} diff --git a/packages/cli/test/fixtures/frameworks/vite/postcss.config.js b/packages/cli/test/fixtures/frameworks/vite/postcss.config.js new file mode 100644 index 000000000..2e7af2b7f --- /dev/null +++ b/packages/cli/test/fixtures/frameworks/vite/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/packages/cli/test/fixtures/frameworks/vite/src/App.vue b/packages/cli/test/fixtures/frameworks/vite/src/App.vue new file mode 100644 index 000000000..be21c8165 --- /dev/null +++ b/packages/cli/test/fixtures/frameworks/vite/src/App.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/packages/cli/test/fixtures/frameworks/vite/src/components/HelloWorld.vue b/packages/cli/test/fixtures/frameworks/vite/src/components/HelloWorld.vue new file mode 100644 index 000000000..02ed36589 --- /dev/null +++ b/packages/cli/test/fixtures/frameworks/vite/src/components/HelloWorld.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/packages/cli/test/fixtures/frameworks/vite/src/index.css b/packages/cli/test/fixtures/frameworks/vite/src/index.css new file mode 100644 index 000000000..b5c61c956 --- /dev/null +++ b/packages/cli/test/fixtures/frameworks/vite/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/cli/test/fixtures/frameworks/vite/src/main.ts b/packages/cli/test/fixtures/frameworks/vite/src/main.ts new file mode 100644 index 000000000..14a813bd3 --- /dev/null +++ b/packages/cli/test/fixtures/frameworks/vite/src/main.ts @@ -0,0 +1,6 @@ +import { createApp } from 'vue' + +import App from './App.vue' +import './assets/main.css' + +createApp(App).mount('#app') diff --git a/packages/cli/test/fixtures/frameworks/vite/tailwind.config.js b/packages/cli/test/fixtures/frameworks/vite/tailwind.config.js new file mode 100644 index 000000000..7141e4528 --- /dev/null +++ b/packages/cli/test/fixtures/frameworks/vite/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/packages/cli/test/fixtures/frameworks/vite/tsconfig.app.json b/packages/cli/test/fixtures/frameworks/vite/tsconfig.app.json new file mode 100644 index 000000000..b279a350b --- /dev/null +++ b/packages/cli/test/fixtures/frameworks/vite/tsconfig.app.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"] +} diff --git a/packages/cli/test/fixtures/frameworks/vite/tsconfig.json b/packages/cli/test/fixtures/frameworks/vite/tsconfig.json new file mode 100644 index 000000000..a2efd4691 --- /dev/null +++ b/packages/cli/test/fixtures/frameworks/vite/tsconfig.json @@ -0,0 +1,7 @@ +{ + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "files": [] +} diff --git a/packages/cli/test/fixtures/frameworks/vite/tsconfig.node.json b/packages/cli/test/fixtures/frameworks/vite/tsconfig.node.json new file mode 100644 index 000000000..559a19300 --- /dev/null +++ b/packages/cli/test/fixtures/frameworks/vite/tsconfig.node.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"], + "noEmit": true + }, + "include": [ + "vite.config.*", + "vitest.config.*", + "cypress.config.*", + "nightwatch.conf.*", + "playwright.config.*" + ] +} diff --git a/packages/cli/test/fixtures/frameworks/vite/vite.config.ts b/packages/cli/test/fixtures/frameworks/vite/vite.config.ts new file mode 100644 index 000000000..48e16d2b4 --- /dev/null +++ b/packages/cli/test/fixtures/frameworks/vite/vite.config.ts @@ -0,0 +1,18 @@ +import { fileURLToPath, URL } from 'node:url' + +import vue from '@vitejs/plugin-vue' +import { defineConfig } from 'vite' +import vueDevTools from 'vite-plugin-vue-devtools' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + vueDevTools(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, +}) diff --git a/packages/cli/test/fixtures/nuxt/.gitignore b/packages/cli/test/fixtures/nuxt/.gitignore deleted file mode 100644 index 4a7f73a2e..000000000 --- a/packages/cli/test/fixtures/nuxt/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Nuxt dev/build outputs -.output -.data -.nuxt -.nitro -.cache -dist - -# Node dependencies -node_modules - -# Logs -logs -*.log - -# Misc -.DS_Store -.fleet -.idea - -# Local env files -.env -.env.* -!.env.example diff --git a/packages/cli/test/fixtures/nuxt/.npmrc b/packages/cli/test/fixtures/nuxt/.npmrc deleted file mode 100644 index c483022c0..000000000 --- a/packages/cli/test/fixtures/nuxt/.npmrc +++ /dev/null @@ -1 +0,0 @@ -shamefully-hoist=true \ No newline at end of file diff --git a/packages/cli/test/fixtures/nuxt/README.md b/packages/cli/test/fixtures/nuxt/README.md deleted file mode 100644 index f5db2a2db..000000000 --- a/packages/cli/test/fixtures/nuxt/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# Nuxt 3 Minimal Starter - -Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. - -## Setup - -Make sure to install the dependencies: - -```bash -# npm -npm install - -# pnpm -pnpm install - -# yarn -yarn install - -# bun -bun install -``` - -## Development Server - -Start the development server on `http://localhost:3000`: - -```bash -# npm -npm run dev - -# pnpm -pnpm run dev - -# yarn -yarn dev - -# bun -bun run dev -``` - -## Production - -Build the application for production: - -```bash -# npm -npm run build - -# pnpm -pnpm run build - -# yarn -yarn build - -# bun -bun run build -``` - -Locally preview production build: - -```bash -# npm -npm run preview - -# pnpm -pnpm run preview - -# yarn -yarn preview - -# bun -bun run preview -``` - -Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. diff --git a/packages/cli/test/fixtures/nuxt/tailwind.config.js b/packages/cli/test/fixtures/nuxt/tailwind.config.js deleted file mode 100644 index 1d96084e4..000000000 --- a/packages/cli/test/fixtures/nuxt/tailwind.config.js +++ /dev/null @@ -1,70 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - darkMode: ['class'], - theme: { - container: { - center: true, - padding: '2rem', - screens: { - '2xl': '1400px', - }, - }, - extend: { - colors: { - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))', - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))', - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))', - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))', - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))', - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))', - }, - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))', - }, - }, - borderRadius: { - lg: 'var(--radius)', - md: 'calc(var(--radius) - 2px)', - sm: 'calc(var(--radius) - 4px)', - }, - keyframes: { - 'accordion-down': { - from: { height: 0 }, - to: { height: 'var(--reka-accordion-content-height)' }, - }, - 'accordion-up': { - from: { height: 'var(--reka-accordion-content-height)' }, - to: { height: 0 }, - }, - }, - animation: { - 'accordion-down': 'accordion-down 0.2s ease-out', - 'accordion-up': 'accordion-up 0.2s ease-out', - }, - }, - }, - plugins: [require('tailwindcss-animate')], -} diff --git a/packages/cli/test/utils/get-config.test.ts b/packages/cli/test/utils/get-config.test.ts index 477265eff..bbf917288 100644 --- a/packages/cli/test/utils/get-config.test.ts +++ b/packages/cli/test/utils/get-config.test.ts @@ -12,14 +12,13 @@ it('get raw config', async () => { await getRawConfig(path.resolve(__dirname, '../fixtures/config-partial')), ).toEqual({ style: 'default', - framework: 'Vite', tailwind: { config: './tailwind.config.ts', css: './src/assets/css/tailwind.css', baseColor: 'neutral', cssVariables: false, }, - tsConfigPath: './tsconfig.json', + // tsConfigPath: './tsconfig.json', aliases: { components: '@/components', utils: '@/lib/utils', @@ -51,13 +50,14 @@ it('get config', async () => { baseColor: 'neutral', cssVariables: false, }, + typescript: true, aliases: { components: '@/components', utils: '@/lib/utils', }, - framework: 'Vite', - tsConfigPath: './tsconfig.json', + // tsConfigPath: './tsconfig.json', resolvedPaths: { + cwd: path.resolve(__dirname, '../fixtures/config-partial'), tailwindConfig: path.resolve( __dirname, '../fixtures/config-partial', @@ -76,21 +76,23 @@ it('get config', async () => { ui: path.resolve( __dirname, '../fixtures/config-partial', - './components', + './components/ui', ), utils: path.resolve( __dirname, '../fixtures/config-partial', './lib/utils', ), + lib: path.resolve(__dirname, '../fixtures/config-partial', './lib'), }, - typescript: true, + iconLibrary: 'lucide', }) expect( await getConfig(path.resolve(__dirname, '../fixtures/config-full')), ).toEqual({ style: 'new-york', + typescript: true, tailwind: { config: 'tailwind.config.ts', baseColor: 'zinc', @@ -100,12 +102,13 @@ it('get config', async () => { }, aliases: { components: '~/components', - ui: '~/ui', utils: '~/lib/utils', + lib: '~/lib', + ui: '~/ui', }, - framework: 'Vite', - tsConfigPath: './tsconfig.json', + iconLibrary: 'lucide', resolvedPaths: { + cwd: path.resolve(__dirname, '../fixtures/config-full'), tailwindConfig: path.resolve( __dirname, '../fixtures/config-full', @@ -121,18 +124,14 @@ it('get config', async () => { '../fixtures/config-full', './src/components', ), - ui: path.resolve( - __dirname, - '../fixtures/config-full', - './src/ui', - ), + ui: path.resolve(__dirname, '../fixtures/config-full', './src/ui'), + lib: path.resolve(__dirname, '../fixtures/config-full', './src/lib'), utils: path.resolve( __dirname, '../fixtures/config-full', './src/lib/utils', ), }, - typescript: true, }) expect( @@ -150,9 +149,9 @@ it('get config', async () => { components: '@/components', utils: '@/lib/utils', }, - framework: 'Vite', - tsConfigPath: './tsconfig.json', + iconLibrary: 'radix', resolvedPaths: { + cwd: path.resolve(__dirname, '../fixtures/config-js'), tailwindConfig: path.resolve( __dirname, '../fixtures/config-js', @@ -168,12 +167,9 @@ it('get config', async () => { '../fixtures/config-js', './components', ), - ui: path.resolve( - __dirname, - '../fixtures/config-js', - './components', - ), + ui: path.resolve(__dirname, '../fixtures/config-js', './components/ui'), utils: path.resolve(__dirname, '../fixtures/config-js', './lib/utils'), + lib: path.resolve(__dirname, '../fixtures/config-js', './lib'), }, }) }) diff --git a/packages/cli/test/utils/get-item-target-path.test.ts b/packages/cli/test/utils/get-item-target-path.test.ts new file mode 100644 index 000000000..6cad37f4c --- /dev/null +++ b/packages/cli/test/utils/get-item-target-path.test.ts @@ -0,0 +1,39 @@ +import path from 'node:path' +import { expect, it } from 'vitest' + +import { getConfig } from '../../src/utils/get-config' +import { getItemTargetPath } from '../../src/utils/registry' + +it('get item target path', async () => { + // Full config. + let appDir = path.resolve(__dirname, '../fixtures/config-full') + expect( + await getItemTargetPath(await getConfig(appDir), { + type: 'registry:ui', + }), + ).toEqual(path.resolve(appDir, './src/ui')) + + // Partial config. + appDir = path.resolve(__dirname, '../fixtures/config-partial') + expect( + await getItemTargetPath(await getConfig(appDir), { + type: 'registry:ui', + }), + ).toEqual(path.resolve(appDir, './components/ui')) + + // JS. + appDir = path.resolve(__dirname, '../fixtures/config-js') + expect( + await getItemTargetPath(await getConfig(appDir), { + type: 'registry:ui', + }), + ).toEqual(path.resolve(appDir, './components/ui')) + + // Custom paths. + appDir = path.resolve(__dirname, '../fixtures/config-ui') + expect( + await getItemTargetPath(await getConfig(appDir), { + type: 'registry:ui', + }), + ).toEqual(path.resolve(appDir, './src/ui')) +}) diff --git a/packages/cli/test/utils/get-project-info.test.ts b/packages/cli/test/utils/get-project-info.test.ts new file mode 100644 index 000000000..52f061c73 --- /dev/null +++ b/packages/cli/test/utils/get-project-info.test.ts @@ -0,0 +1,36 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +import { FRAMEWORKS } from '../../src/utils/frameworks' +import { getProjectInfo } from '../../src/utils/get-project-info' + +describe('get project info', async () => { + it.each([ + { + name: 'nuxt', + type: { + framework: FRAMEWORKS.nuxt, + typescript: true, + tailwindConfigFile: 'tailwind.config.ts', + tailwindCssFile: 'assets/css/tailwind.css', + aliasPrefix: '@', + }, + }, + { + name: 'vite', + type: { + framework: FRAMEWORKS.vite, + typescript: true, + tailwindConfigFile: 'tailwind.config.js', + tailwindCssFile: 'src/index.css', + aliasPrefix: null, + }, + }, + ])(`getProjectType($name) -> $type`, async ({ name, type }) => { + expect( + await getProjectInfo( + path.resolve(__dirname, `../fixtures/frameworks/${name}`), + ), + ).toStrictEqual(type) + }) +}) diff --git a/packages/cli/test/utils/get-tailwind-css-file.test.ts b/packages/cli/test/utils/get-tailwind-css-file.test.ts new file mode 100644 index 000000000..1ab624065 --- /dev/null +++ b/packages/cli/test/utils/get-tailwind-css-file.test.ts @@ -0,0 +1,23 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +import { getTailwindCssFile } from '../../src/utils/get-project-info' + +describe('get tailwindcss file', async () => { + it.each([ + { + name: 'nuxt', + file: 'assets/css/tailwind.css', + }, + { + name: 'vite', + file: 'src/index.css', + }, + ])(`getTailwindCssFile($name) -> $file`, async ({ name, file }) => { + expect( + await getTailwindCssFile( + path.resolve(__dirname, `../fixtures/frameworks/${name}`), + ), + ).toBe(file) + }) +}) diff --git a/packages/cli/test/utils/get-ts-config-alias-prefix.test.ts b/packages/cli/test/utils/get-ts-config-alias-prefix.test.ts new file mode 100644 index 000000000..7154465ca --- /dev/null +++ b/packages/cli/test/utils/get-ts-config-alias-prefix.test.ts @@ -0,0 +1,27 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +import { getTsConfigAliasPrefix } from '../../src/utils/get-project-info' + +describe('get ts config alias prefix', async () => { + it.each([ + { + name: 'nuxt', + prefix: '@', + }, + // { + // name: 'vite', + // prefix: '@', + // }, + // { + // name: 'next-app-custom-alias', + // prefix: '@custom-alias', + // }, + ])(`getTsConfigAliasPrefix($name) -> $prefix`, async ({ name, prefix }) => { + expect( + await getTsConfigAliasPrefix( + path.resolve(__dirname, `../fixtures/frameworks/${name}`), + ), + ).toBe(prefix) + }) +}) diff --git a/packages/cli/test/utils/is-typescript-project.test.ts b/packages/cli/test/utils/is-typescript-project.test.ts new file mode 100644 index 000000000..5d8636546 --- /dev/null +++ b/packages/cli/test/utils/is-typescript-project.test.ts @@ -0,0 +1,19 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +import { isTypeScriptProject } from '../../src/utils/get-project-info' + +describe('is TypeScript project', async () => { + it.each([ + { + name: 'nuxt', + result: true, + }, + ])(`isTypeScriptProject($name) -> $result`, async ({ name, result }) => { + expect( + await isTypeScriptProject( + path.resolve(__dirname, `../fixtures/frameworks/${name}`), + ), + ).toBe(result) + }) +}) diff --git a/packages/cli/test/utils/schema/__snapshots__/registry-resolve-items-tree.test.ts.snap b/packages/cli/test/utils/schema/__snapshots__/registry-resolve-items-tree.test.ts.snap new file mode 100644 index 000000000..c1c9205aa --- /dev/null +++ b/packages/cli/test/utils/schema/__snapshots__/registry-resolve-items-tree.test.ts.snap @@ -0,0 +1,951 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`registryResolveItemTree > should resolve index 1`] = ` +{ + "cssVars": { + "dark": {}, + "light": { + "radius": "0.5rem", + }, + }, + "dependencies": [ + "tailwindcss-animate", + "class-variance-authority", + "lucide-vue-next", + "clsx", + "tailwind-merge", + ], + "devDependencies": [], + "docs": "", + "files": [ + { + "content": "import type { Updater } from '@tanstack/vue-table' +import type { Ref } from 'vue' +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export function valueUpdater>(updaterOrValue: T, ref: Ref) { + ref.value + = typeof updaterOrValue === 'function' + ? updaterOrValue(ref.value) + : updaterOrValue +} +", + "path": "lib/utils.ts", + "target": "", + "type": "registry:lib", + }, + { + "content": " + + +", + "path": "ui/label/Label.vue", + "target": "label/Label.vue", + "type": "registry:ui", + }, + { + "content": "export { default as Label } from './Label.vue' +", + "path": "ui/label/index.ts", + "target": "label/index.ts", + "type": "registry:ui", + }, + ], + "tailwind": { + "config": { + "plugins": [ + "require("tailwindcss-animate")", + ], + "theme": { + "extend": { + "borderRadius": { + "lg": "var(--radius)", + "md": "calc(var(--radius) - 2px)", + "sm": "calc(var(--radius) - 4px)", + }, + "colors": {}, + }, + }, + }, + }, +} +`; + +exports[`registryResolveItemTree > should resolve items tree 1`] = ` +{ + "cssVars": {}, + "dependencies": [ + "clsx", + "tailwind-merge", + ], + "devDependencies": [], + "docs": "", + "files": [ + { + "content": " + + +", + "path": "ui/button/Button.vue", + "target": "button/Button.vue", + "type": "registry:ui", + }, + { + "content": "import { cva, type VariantProps } from 'class-variance-authority' + +export { default as Button } from './Button.vue' + +export const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: + 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + xs: 'h-7 rounded px-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +export type ButtonVariants = VariantProps +", + "path": "ui/button/index.ts", + "target": "button/index.ts", + "type": "registry:ui", + }, + { + "content": "import type { Updater } from '@tanstack/vue-table' +import type { Ref } from 'vue' +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export function valueUpdater>(updaterOrValue: T, ref: Ref) { + ref.value + = typeof updaterOrValue === 'function' + ? updaterOrValue(ref.value) + : updaterOrValue +} +", + "path": "lib/utils.ts", + "target": "", + "type": "registry:lib", + }, + ], + "tailwind": {}, +} +`; + +exports[`registryResolveItemTree > should resolve multiple items tree 1`] = ` +{ + "cssVars": {}, + "dependencies": [ + "clsx", + "tailwind-merge", + "@vueuse/core", + ], + "devDependencies": [], + "docs": "", + "files": [ + { + "content": " + + +", + "path": "ui/button/Button.vue", + "target": "button/Button.vue", + "type": "registry:ui", + }, + { + "content": "import { cva, type VariantProps } from 'class-variance-authority' + +export { default as Button } from './Button.vue' + +export const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: + 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +export type ButtonVariants = VariantProps +", + "path": "ui/button/index.ts", + "target": "button/index.ts", + "type": "registry:ui", + }, + { + "content": "import type { Updater } from '@tanstack/vue-table' +import type { Ref } from 'vue' +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export function valueUpdater>(updaterOrValue: T, ref: Ref) { + ref.value + = typeof updaterOrValue === 'function' + ? updaterOrValue(ref.value) + : updaterOrValue +} +", + "path": "lib/utils.ts", + "target": "", + "type": "registry:lib", + }, + { + "content": " + + +", + "path": "ui/input/Input.vue", + "target": "input/Input.vue", + "type": "registry:ui", + }, + { + "content": "export { default as Input } from './Input.vue' +", + "path": "ui/input/index.ts", + "target": "input/index.ts", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/command/Command.vue", + "target": "command/Command.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/command/CommandDialog.vue", + "target": "command/CommandDialog.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/command/CommandEmpty.vue", + "target": "command/CommandEmpty.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/command/CommandGroup.vue", + "target": "command/CommandGroup.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/command/CommandInput.vue", + "target": "command/CommandInput.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/command/CommandItem.vue", + "target": "command/CommandItem.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/command/CommandList.vue", + "target": "command/CommandList.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/command/CommandSeparator.vue", + "target": "command/CommandSeparator.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/command/CommandShortcut.vue", + "target": "command/CommandShortcut.vue", + "type": "registry:ui", + }, + { + "content": "export { default as Command } from './Command.vue' +export { default as CommandDialog } from './CommandDialog.vue' +export { default as CommandEmpty } from './CommandEmpty.vue' +export { default as CommandGroup } from './CommandGroup.vue' +export { default as CommandInput } from './CommandInput.vue' +export { default as CommandItem } from './CommandItem.vue' +export { default as CommandList } from './CommandList.vue' +export { default as CommandSeparator } from './CommandSeparator.vue' +export { default as CommandShortcut } from './CommandShortcut.vue' +", + "path": "ui/command/index.ts", + "target": "command/index.ts", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/dialog/Dialog.vue", + "target": "dialog/Dialog.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/dialog/DialogClose.vue", + "target": "dialog/DialogClose.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/dialog/DialogContent.vue", + "target": "dialog/DialogContent.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/dialog/DialogDescription.vue", + "target": "dialog/DialogDescription.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/dialog/DialogFooter.vue", + "target": "dialog/DialogFooter.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/dialog/DialogHeader.vue", + "target": "dialog/DialogHeader.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/dialog/DialogScrollContent.vue", + "target": "dialog/DialogScrollContent.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/dialog/DialogTitle.vue", + "target": "dialog/DialogTitle.vue", + "type": "registry:ui", + }, + { + "content": " + + +", + "path": "ui/dialog/DialogTrigger.vue", + "target": "dialog/DialogTrigger.vue", + "type": "registry:ui", + }, + { + "content": "export { default as Dialog } from './Dialog.vue' +export { default as DialogClose } from './DialogClose.vue' +export { default as DialogContent } from './DialogContent.vue' +export { default as DialogDescription } from './DialogDescription.vue' +export { default as DialogFooter } from './DialogFooter.vue' +export { default as DialogHeader } from './DialogHeader.vue' +export { default as DialogScrollContent } from './DialogScrollContent.vue' +export { default as DialogTitle } from './DialogTitle.vue' +export { default as DialogTrigger } from './DialogTrigger.vue' +", + "path": "ui/dialog/index.ts", + "target": "dialog/index.ts", + "type": "registry:ui", + }, + ], + "tailwind": {}, +} +`; diff --git a/packages/cli/test/utils/schema/registry-resolve-items-tree.test.ts b/packages/cli/test/utils/schema/registry-resolve-items-tree.test.ts new file mode 100644 index 000000000..4893ef74c --- /dev/null +++ b/packages/cli/test/utils/schema/registry-resolve-items-tree.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { registryResolveItemsTree } from '../../../src/utils/registry' + +describe('registryResolveItemTree', () => { + it('should resolve items tree', async () => { + expect( + await registryResolveItemsTree(['button'], { + style: 'new-york', + tailwind: { + baseColor: 'stone', + }, + }), + ).toMatchSnapshot() + }) + + it('should resolve multiple items tree', async () => { + expect( + await registryResolveItemsTree(['button', 'input', 'command'], { + style: 'default', + tailwind: { + baseColor: 'zinc', + }, + }), + ).toMatchSnapshot() + }) + + it('should resolve index', async () => { + expect( + await registryResolveItemsTree(['index', 'label'], { + style: 'default', + tailwind: { + baseColor: 'zinc', + }, + }), + ).toMatchSnapshot() + }) +}) diff --git a/packages/cli/test/utils/updaters/__snapshots__/update-tailwind-config.test.ts.snap b/packages/cli/test/utils/updaters/__snapshots__/update-tailwind-config.test.ts.snap new file mode 100644 index 000000000..ec6f16468 --- /dev/null +++ b/packages/cli/test/utils/updaters/__snapshots__/update-tailwind-config.test.ts.snap @@ -0,0 +1,572 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`transformTailwindConfig -> darkMode property > should add darkMode property if not in config 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + " +`; + +exports[`transformTailwindConfig -> darkMode property > should add darkMode property if not in config 2`] = ` +"/** @type {import('tailwindcss').Config} */ + +export default { + darkMode: ['class'], + content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], + theme: { + extend: {}, + }, + plugins: [], +} + " +`; + +exports[`transformTailwindConfig -> darkMode property > should add darkMode property if not in config 3`] = ` +"/** @type {import('tailwindcss').Config} */ +const foo = { + bar: 'baz', +} + +export default { + darkMode: ['class'], + content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], + theme: { + extend: {}, + }, + plugins: [], +} + " +`; + +exports[`transformTailwindConfig -> darkMode property > should append class to darkMode property if existing array 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ["selector", "class"], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + " +`; + +exports[`transformTailwindConfig -> darkMode property > should convert string to array and add class if darkMode is string 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ["selector", "class"], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + " +`; + +exports[`transformTailwindConfig -> darkMode property > should not add darkMode property if already in config 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ['class'], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + " +`; + +exports[`transformTailwindConfig -> darkMode property > should not add darkMode property if already in config 2`] = ` +"import type { Config } from 'tailwindcss' + + const config: Config = { + darkMode: ['class', 'selector'], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], + } + export default config + " +`; + +exports[`transformTailwindConfig -> darkMode property > should preserve quote kind 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ['selector', '[data-mode="dark"]', 'class'], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + " +`; + +exports[`transformTailwindConfig -> darkMode property > should work with multiple darkMode selectors 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ['variant', [ + '@media (prefers-color-scheme: dark) { &:not(.light *) }', + '&:is(.dark *)', + ], 'class'], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + " +`; + +exports[`transformTailwindConfig -> plugin > should add plugin if not in config 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [require("tailwindcss-animate")] +} +export default config + " +`; + +exports[`transformTailwindConfig -> plugin > should append plugin to existing array 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [require("@tailwindcss/typography"), require("tailwindcss-animate")], +} +export default config + " +`; + +exports[`transformTailwindConfig -> plugin > should not add plugin if already in config 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [require("@tailwindcss/typography"), require("tailwindcss-animate")], +} +export default config + " +`; + +exports[`transformTailwindConfig -> theme > should add theme if not in config 1`] = ` +"import type { Config } from 'tailwindcss' + + const config: Config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + } + } + } + } +} + export default config + " +`; + +exports[`transformTailwindConfig -> theme > should handle multiple properties 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + fontFamily: { + sans: [ + 'var(--font-geist-sans)', + ...fontFamily.sans + ], + mono: [ + 'var(--font-mono)', + ...fontFamily.mono + ], + heading: [ + 'var(--font-geist-sans)' + ] + }, + colors: { + ...defaultColors, + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + } + }, + boxShadow: { + ...defaultBoxShadow, + '3xl': '0 35px 60px -15px rgba(0, 0, 0, 0.3)' + }, + borderRadius: { + '3xl': '2rem', + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + animation: { + ...defaultAnimation, + 'spin-slow': 'spin 3s linear infinite', + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out' + } + } + }, +} +export default config + " +`; + +exports[`transformTailwindConfig -> theme > should handle objects nested in arrays 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + fontSize: { + xs: [ + '0.75rem', + { + lineHeight: '1rem' + } + ], + sm: [ + '0.875rem', + { + lineHeight: '1.25rem' + } + ], + xl: [ + 'clamp(1.5rem, 1.04vi + 1.17rem, 2rem)', + { + lineHeight: '1.2', + letterSpacing: '-0.02em', + fontWeight: '600' + } + ] + } + } + }, +} +export default config + " +`; + +exports[`transformTailwindConfig -> theme > should keep arrays when formatted on multilines 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + fontFamily: { + sans: [ + 'Figtree', + ...defaultTheme.fontFamily.sans + ], + mono: [ + 'Foo' + ] + } + } + }, +} +export default config + " +`; + +exports[`transformTailwindConfig -> theme > should keep quotes in strings 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + fontFamily: { + sans: [ + 'Figtree', + ...defaultTheme.fontFamily.sans + ] + }, + colors: { + ...defaultColors, + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + } + } + } + }, +} +export default config + " +`; + +exports[`transformTailwindConfig -> theme > should keep spread assignments 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + ...defaultColors, + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + } + } + } + }, +} +export default config + " +`; + +exports[`transformTailwindConfig -> theme > should merge existing theme 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + fontFamily: { + sans: [ + 'ui-sans-serif', + 'sans-serif' + ] + }, + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + } + } + } + }, +} +export default config + " +`; + +exports[`transformTailwindConfig -> theme > should preserve boolean values 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + container: { + center: true + } + }, +} +export default config + " +`; diff --git a/packages/cli/test/utils/updaters/__snapshots__/update-tailwind-content.test.ts.snap b/packages/cli/test/utils/updaters/__snapshots__/update-tailwind-content.test.ts.snap new file mode 100644 index 000000000..6f82d6b72 --- /dev/null +++ b/packages/cli/test/utils/updaters/__snapshots__/update-tailwind-content.test.ts.snap @@ -0,0 +1,52 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`transformTailwindContent -> content property > should NOT add content property if already in config 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + "./bar/**/*.{js,ts,jsx,tsx,mdx}" + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + " +`; + +exports[`transformTailwindContent -> content property > should add content property if not in config 1`] = ` +"import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + "./foo/**/*.{js,ts,jsx,tsx,mdx}", + "./bar/**/*.{js,ts,jsx,tsx,mdx}" + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + " +`; diff --git a/packages/cli/test/utils/updaters/update-css-vars.test.ts b/packages/cli/test/utils/updaters/update-css-vars.test.ts new file mode 100644 index 000000000..a21c6673c --- /dev/null +++ b/packages/cli/test/utils/updaters/update-css-vars.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest' + +import { transformCssVars } from '../../../src/utils/updaters/update-css-vars' + +describe('transformCssVars', () => { + it('should add light and dark css vars if not present', async () => { + expect( + await transformCssVars( + `@tailwind base; +@tailwind components; +@tailwind utilities; + `, + { + light: { + background: 'white', + foreground: 'black', + }, + dark: { + background: 'black', + foreground: 'white', + }, + }, + { + tailwind: { + cssVariables: true, + }, + }, + ), + ).toMatchInlineSnapshot(` + "@tailwind base; + @tailwind components; + @tailwind utilities; + @layer base { + :root { + --background: white; + --foreground: black + } + .dark { + --background: black; + --foreground: white + } + } + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + it('should update light and dark css vars if present', async () => { + expect( + await transformCssVars( + `@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base{ + :root{ + --background: 210 40% 98%; + } + + .dark{ + --background: 222.2 84% 4.9%; + } +} + `, + { + light: { + background: '215 20.2% 65.1%', + foreground: '222.2 84% 4.9%', + }, + dark: { + foreground: '60 9.1% 97.8%', + }, + }, + { + tailwind: { + cssVariables: true, + }, + }, + ), + ).toMatchInlineSnapshot(` + "@tailwind base; + @tailwind components; + @tailwind utilities; + + @layer base{ + :root{ + --background: 215 20.2% 65.1%; + --foreground: 222.2 84% 4.9%; + } + + .dark{ + --background: 222.2 84% 4.9%; + --foreground: 60 9.1% 97.8%; + } + } + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) + + it('should not add the base layer if it is already present', async () => { + expect( + await transformCssVars( + `@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base{ + :root{ + --background: 210 40% 98%; + } + + .dark{ + --background: 222.2 84% 4.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + `, + {}, + { + tailwind: { + cssVariables: true, + }, + }, + ), + ).toMatchInlineSnapshot(` + "@tailwind base; + @tailwind components; + @tailwind utilities; + + @layer base{ + :root{ + --background: 210 40% 98%; + } + + .dark{ + --background: 222.2 84% 4.9%; + } + } + + @layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } + } + " + `) + }) +}) diff --git a/packages/cli/test/utils/updaters/update-files.test.ts b/packages/cli/test/utils/updaters/update-files.test.ts new file mode 100644 index 000000000..43fb4bff0 --- /dev/null +++ b/packages/cli/test/utils/updaters/update-files.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' + +import { resolveTargetDir } from '../../../src/utils/updaters/update-files' + +describe('resolveTargetDir', () => { + it('should handle a home target without a src directory', () => { + const targetDir = resolveTargetDir( + { + isSrcDir: false, + }, + { + resolvedPaths: { + cwd: '/foo/bar', + }, + }, + '~/.env', + ) + expect(targetDir).toBe('/foo/bar/.env') + }) + + it('should handle a home target even with a src directory', () => { + const targetDir = resolveTargetDir( + { + isSrcDir: true, + }, + { + resolvedPaths: { + cwd: '/foo/bar', + }, + }, + '~/.env', + ) + expect(targetDir).toBe('/foo/bar/.env') + }) + + it('should handle a simple target', () => { + const targetDir = resolveTargetDir( + { + isSrcDir: false, + }, + { + resolvedPaths: { + cwd: '/foo/bar', + }, + }, + './components/ui/button.tsx', + ) + expect(targetDir).toBe('/foo/bar/components/ui/button.tsx') + }) + + it('should handle a simple target with src directory', () => { + const targetDir = resolveTargetDir( + { + isSrcDir: true, + }, + { + resolvedPaths: { + cwd: '/foo/bar', + }, + }, + './components/ui/button.tsx', + ) + expect(targetDir).toBe('/foo/bar/src/components/ui/button.tsx') + }) +}) diff --git a/packages/cli/test/utils/updaters/update-tailwind-config.test.ts b/packages/cli/test/utils/updaters/update-tailwind-config.test.ts new file mode 100644 index 000000000..fc26eb24c --- /dev/null +++ b/packages/cli/test/utils/updaters/update-tailwind-config.test.ts @@ -0,0 +1,1383 @@ +import { Project, SyntaxKind } from 'ts-morph' +import { beforeEach, describe, expect, it } from 'vitest' + +import { + buildTailwindThemeColorsFromCssVars, + nestSpreadElements, + nestSpreadProperties, + transformTailwindConfig, + unnestSpreadProperties, + unnsetSpreadElements, +} from '../../../src/utils/updaters/update-tailwind-config' + +const SHARED_CONFIG = { + $schema: 'https://shadcn-vue.com/schema.json', + style: 'new-york', + rsc: true, + tsx: true, + tailwind: { + config: 'tailwind.config.ts', + css: 'app/globals.css', + baseColor: 'slate', + cssVariables: true, + }, + aliases: { + components: '@/components', + utils: '@/lib/utils', + }, + resolvedPaths: { + cwd: '.', + tailwindConfig: 'tailwind.config.ts', + tailwindCss: 'app/globals.css', + components: './components', + utils: './lib/utils', + ui: './components/ui', + }, +} + +describe('transformTailwindConfig -> darkMode property', () => { + it('should add darkMode property if not in config', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + `, + { + properties: [ + { + name: 'darkMode', + value: 'class', + }, + ], + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + + expect( + await transformTailwindConfig( + `/** @type {import('tailwindcss').Config} */ + +export default { + content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], + theme: { + extend: {}, + }, + plugins: [], +} + `, + { + properties: [ + { + name: 'darkMode', + value: 'class', + }, + ], + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + + expect( + await transformTailwindConfig( + `/** @type {import('tailwindcss').Config} */ +const foo = { + bar: 'baz', +} + +export default { + content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], + theme: { + extend: {}, + }, + plugins: [], +} + `, + { + properties: [ + { + name: 'darkMode', + value: 'class', + }, + ], + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) + + it('should append class to darkMode property if existing array', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ["selector"], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + `, + { + properties: [ + { + name: 'darkMode', + value: 'class', + }, + ], + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) + + it('should preserve quote kind', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ['selector', '[data-mode="dark"]'], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + `, + { + properties: [ + { + name: 'darkMode', + value: 'class', + }, + ], + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) + + it('should convert string to array and add class if darkMode is string', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: "selector", + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + `, + { + properties: [ + { + name: 'darkMode', + value: 'class', + }, + ], + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) + + it('should work with multiple darkMode selectors', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ['variant', [ + '@media (prefers-color-scheme: dark) { &:not(.light *) }', + '&:is(.dark *)', + ]], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + `, + { + properties: [ + { + name: 'darkMode', + value: 'class', + }, + ], + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) + + it('should not add darkMode property if already in config', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ['class'], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + `, + { + properties: [ + { + name: 'darkMode', + value: 'class', + }, + ], + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + + const config: Config = { + darkMode: ['class', 'selector'], + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], + } + export default config + `, + { + properties: [ + { + name: 'darkMode', + value: 'class', + }, + ], + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) +}) + +describe('transformTailwindConfig -> plugin', () => { + it('should add plugin if not in config', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, +} +export default config + `, + { + plugins: ['require("tailwindcss-animate")'], + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) + + it('should append plugin to existing array', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [require("@tailwindcss/typography")], +} +export default config + `, + { + plugins: ['require("tailwindcss-animate")'], + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) + + it('should not add plugin if already in config', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [require("@tailwindcss/typography"), require("tailwindcss-animate")], +} +export default config + `, + { + plugins: ['require(\'tailwindcss-animate\')'], + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) +}) + +describe('transformTailwindConfig -> theme', () => { + it('should add theme if not in config', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + + const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + } + export default config + `, + { + theme: { + extend: { + colors: { + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + }, + }, + }, + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) + + it('should merge existing theme', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + fontFamily: { + sans: [ + "ui-sans-serif", + "sans-serif", + ], + }, + colors: { + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + }, + }, + }, +} +export default config + `, + { + theme: { + extend: { + colors: { + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + }, + }, + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) + + it('should keep spread assignments', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + ...defaultColors, + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + }, + }, + }, +} +export default config + `, + { + theme: { + extend: { + colors: { + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + }, + }, + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) + + it('should handle multiple properties', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + fontFamily: { + sans: ["var(--font-geist-sans)", ...fontFamily.sans], + mono: ["var(--font-mono)", ...fontFamily.mono], + }, + colors: { + ...defaultColors, + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + }, + boxShadow: { + ...defaultBoxShadow, + "3xl": "0 35px 60px -15px rgba(0, 0, 0, 0.3)", + }, + borderRadius: { + "3xl": "2rem", + }, + animation: { + ...defaultAnimation, + "spin-slow": "spin 3s linear infinite", + }, + }, + }, +} +export default config + `, + { + theme: { + extend: { + fontFamily: { + heading: ['var(--font-geist-sans)'], + }, + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, + }, + }, + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) + + it('should not make any updates running on already updated config', async () => { + const input = `import type { Config } from 'tailwindcss' + +const config: Config = { +content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", +], +theme: { + extend: { + fontFamily: { + sans: ["var(--font-geist-sans)", ...fontFamily.sans], + mono: ["var(--font-mono)", ...fontFamily.mono], + }, + colors: { + ...defaultColors, + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + }, + boxShadow: { + ...defaultBoxShadow, + "3xl": "0 35px 60px -15px rgba(0, 0, 0, 0.3)", + }, + borderRadius: { + "3xl": "2rem", + }, + animation: { + ...defaultAnimation, + "spin-slow": "spin 3s linear infinite", + }, + }, +}, +} +export default config +` + + const tailwindConfig = { + theme: { + extend: { + fontFamily: { + heading: ['var(--font-geist-sans)'], + }, + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, + }, + }, + } + + const output1 = await transformTailwindConfig(input, tailwindConfig, { + config: SHARED_CONFIG, + }) + + const output2 = await transformTailwindConfig(output1, tailwindConfig, { + config: SHARED_CONFIG, + }) + + const output3 = await transformTailwindConfig(output2, tailwindConfig, { + config: SHARED_CONFIG, + }) + + expect(output3).toBe(output1) + expect(output3).toBe(output2) + }) + + it('should keep quotes in strings', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + fontFamily: { + sans: ['Figtree', ...defaultTheme.fontFamily.sans], + }, + colors: { + ...defaultColors, + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + }, + }, + }, +} +export default config + `, + { + theme: { + extend: { + colors: { + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + }, + }, + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) + + it('should keep arrays when formatted on multilines', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + fontFamily: { + sans: [ + 'Figtree', + ...defaultTheme.fontFamily.sans + ], + }, + }, + }, +} +export default config + `, + { + theme: { + extend: { + fontFamily: { + mono: ['Foo'], + }, + }, + }, + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) + + it('should handle objects nested in arrays', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + fontSize: { + xs: ['0.75rem', { lineHeight: '1rem' }], + sm: [ + '0.875rem', + { + lineHeight: '1.25rem', + }, + ], + }, + }, + }, +} +export default config + `, + { + theme: { + extend: { + fontSize: { + xl: [ + 'clamp(1.5rem, 1.04vi + 1.17rem, 2rem)', + { + lineHeight: '1.2', + letterSpacing: '-0.02em', + fontWeight: '600', + }, + ], + }, + }, + }, + }, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) + + it('should preserve boolean values', async () => { + expect( + await transformTailwindConfig( + `import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + container: { + center: true + } + }, +} +export default config + `, + {}, + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) +}) + +describe('nestSpreadProperties', () => { + let project: Project + + beforeEach(() => { + project = new Project({ useInMemoryFileSystem: true }) + }) + + function testTransformation(input: string, expected: string) { + const sourceFile = project.createSourceFile( + 'test.ts', + `const config = ${input};`, + ) + const configObject = sourceFile.getFirstDescendantByKind( + SyntaxKind.ObjectLiteralExpression, + ) + if (!configObject) + throw new Error('Config object not found') + + nestSpreadProperties(configObject) + + const result = configObject.getText() + expect(result.replace(/\s+/g, '')).toBe(expected.replace(/\s+/g, '')) + } + + it('should nest spread properties', () => { + testTransformation( + `{ theme: { ...foo, bar: { ...baz, one: "two" }, other: { a: "b", ...c } } }`, + `{ theme: { "___foo": "...foo", bar: { "___baz": "...baz", one: "two" }, other: { a: "b", "___c": "...c" } } }`, + ) + }) + + it('should handle mixed property assignments', () => { + testTransformation( + `{ ...foo, a: 1, b() {}, ...bar, c: { ...baz } }`, + `{ "___foo": "...foo", a: 1, b() {}, "___bar": "...bar", c: { "___baz": "...baz" } }`, + ) + }) + + it('should handle objects with only spread properties', () => { + testTransformation( + `{ ...foo, ...bar, ...baz }`, + `{ "___foo": "...foo", "___bar": "...bar", "___baz": "...baz" }`, + ) + }) + + it('should handle property name conflicts', () => { + testTransformation(`{ foo: 1, ...foo }`, `{ foo: 1, "___foo": "...foo" }`) + }) + + it('should handle shorthand property names', () => { + testTransformation(`{ a, ...foo, b }`, `{ a, "___foo": "...foo", b }`) + }) + + it('should handle computed property names', () => { + testTransformation( + `{ ["computed"]: 1, ...foo }`, + `{ ["computed"]: 1, "___foo": "...foo" }`, + ) + }) + + it('should handle spreads in arrays', () => { + testTransformation( + `{ foo: [{ ...bar }] }`, + `{ foo: [{ "___bar": "...bar" }] }`, + ) + }) + + it('should handle deep nesting in arrays', () => { + testTransformation( + `{ foo: [{ baz: { ...other.baz }, ...bar }] }`, + `{ foo: [{ baz: { "___other.baz": "...other.baz" }, "___bar": "...bar" }] }`, + ) + }) +}) + +describe('nestSpreadElements', () => { + let project: Project + + beforeEach(() => { + project = new Project({ useInMemoryFileSystem: true }) + }) + + function testTransformation(input: string, expected: string) { + const sourceFile = project.createSourceFile( + 'test.ts', + `const config = ${input};`, + ) + const configObject = sourceFile.getFirstDescendantByKind( + SyntaxKind.ArrayLiteralExpression, + ) + if (!configObject) + throw new Error('Config object not found') + + nestSpreadElements(configObject) + + const result = configObject.getText() + expect(result.replace(/\s+/g, '')).toBe(expected.replace(/\s+/g, '')) + } + + it('should spread elements', () => { + testTransformation( + `[...bar]`, + `["...bar"]`, + ) + }) + + it('should handle mixed element types', () => { + testTransformation( + `['foo', 2, true, ...bar, "baz"]`, + `['foo', 2, true, "...bar", "baz"]`, + ) + }) + + it('should handle arrays with only spread elements', () => { + testTransformation( + `[...foo, ...foo.bar, ...baz]`, + `["...foo", "...foo.bar", "...baz"]`, + ) + }) + + it('should handle nested arrays with spreads', () => { + testTransformation( + `[...foo, [...bar]]`, + `["...foo", ["...bar"]]`, + ) + }) + + it('should handle nested arrays within objects', () => { + testTransformation( + `[{ foo: [...foo] }]`, + `[{ foo: ["...foo"] }]`, + ) + }) + + it('should handle deeply nested arrays within spread objects', () => { + testTransformation( + `[{ foo: [...foo, { bar: ['bar', ...bar ]}] }]`, + `[{ foo: ["...foo", { bar: ['bar', "...bar" ]}] }]`, + ) + }) + + it('should handle optional paths in spread', () => { + testTransformation( + `[{ foo: [...foo?.bar] }]`, + `[{ foo: ["...foo?.bar"] }]`, + ) + }) + + it('should handle computed property paths within spread', () => { + testTransformation( + `[{ foo: [...foo["bar"]] }]`, + `[{ foo: ["...foo["bar"]"] }]`, + ) + }) + + it('should handle indexed paths in spread', () => { + testTransformation( + `[{ foo: [...foo[0]] }]`, + `[{ foo: ["...foo[0]"] }]`, + ) + }) +}) + +describe('unnestSpreadProperties', () => { + let project: Project + + beforeEach(() => { + project = new Project({ useInMemoryFileSystem: true }) + }) + + function testTransformation(input: string, expected: string) { + const sourceFile = project.createSourceFile( + 'test.ts', + `const config = ${input};`, + ) + const configObject = sourceFile.getFirstDescendantByKind( + SyntaxKind.ObjectLiteralExpression, + ) + if (!configObject) + throw new Error('Config object not found') + + unnestSpreadProperties(configObject) + + const result = configObject.getText() + expect(result.replace(/\s+/g, '')).toBe(expected.replace(/\s+/g, '')) + } + + it('should nest spread properties', () => { + testTransformation( + `{ theme: { ___foo: "...foo", bar: { ___baz: "...baz", one: "two" }, other: { a: "b", ___c: "...c" } } }`, + `{ theme: { ...foo, bar: { ...baz, one: "two" }, other: { a: "b", ...c } } }`, + ) + }) + + it('should handle mixed property assignments', () => { + testTransformation( + `{ ___foo: "...foo", a: 1, b() {}, ___bar: "...bar", c: { ___baz: "...baz" } }`, + `{ ...foo, a: 1, b() {}, ...bar, c: { ...baz } }`, + ) + }) + + it('should handle objects with only spread properties', () => { + testTransformation( + `{ ___foo: "...foo", ___bar: "...bar", ___baz: "...baz" }`, + `{ ...foo, ...bar, ...baz }`, + ) + }) + + it('should handle property name conflicts', () => { + testTransformation(`{ foo: 1, ___foo: "...foo" }`, `{ foo: 1, ...foo }`) + }) + + it('should handle shorthand property names', () => { + testTransformation(`{ a, ___foo: "...foo", b }`, `{ a, ...foo, b }`) + }) + + it('should handle computed property names', () => { + testTransformation( + `{ ["computed"]: 1, "___foo": "...foo" }`, + `{ ["computed"]: 1, ...foo }`, + ) + }) + + it('should handle spread objects within arrays', () => { + testTransformation( + `{ ["computed"]: 1, foo: [{ "___foo": "...foo" }] }`, + `{ ["computed"]: 1, foo: [{...foo}] }`, + ) + }) + + it('should handle deeply nested spread objects within an array', () => { + testTransformation( + `{ ["computed"]: 1, foo: [{ "___foo": "...foo", bar: { baz: 'baz', "___foo.bar": "...foo.bar" } }] }`, + `{ ["computed"]: 1, foo: [{...foo, bar: { baz: 'baz', ...foo.bar } }] }`, + ) + }) +}) + +describe('unnestSpreadElements', () => { + let project: Project + + beforeEach(() => { + project = new Project({ useInMemoryFileSystem: true }) + }) + + function testTransformation(input: string, expected: string) { + const sourceFile = project.createSourceFile( + 'test.ts', + `const config = ${input};`, + ) + const configObject = sourceFile.getFirstDescendantByKind( + SyntaxKind.ArrayLiteralExpression, + ) + if (!configObject) + throw new Error('Config object not found') + + unnsetSpreadElements(configObject) + + const result = configObject.getText() + expect(result.replace(/\s+/g, '')).toBe(expected.replace(/\s+/g, '')) + } + + it('should spread elements', () => { + testTransformation( + `["...bar"]`, + `[...bar]`, + ) + }) + + it('should handle mixed element types', () => { + testTransformation( + `['foo', 2, true, "...bar", "baz"]`, + `['foo', 2, true, ...bar, "baz"]`, + ) + }) + + it('should handle arrays with only spread elements', () => { + testTransformation( + `["...foo", "...foo.bar", "...baz"]`, + `[...foo, ...foo.bar, ...baz]`, + ) + }) + + it('should handle nested arrays with spreads', () => { + testTransformation( + `["...foo", ["...bar"]]`, + `[...foo, [...bar]]`, + ) + }) + + it('should handle nested arrays within objects', () => { + testTransformation( + `[{ foo: ["...foo"] }]`, + `[{ foo: [...foo] }]`, + ) + }) + + it('should handle deeply nested arrays within spread objects', () => { + testTransformation( + `[{ foo: ["...foo", { bar: ['bar', "...bar" ]}] }]`, + `[{ foo: [...foo, { bar: ['bar', ...bar ]}] }]`, + ) + }) + + it('should handle optional paths in spread', () => { + testTransformation( + `[{ foo: ["...foo?.bar"] }]`, + `[{ foo: [...foo?.bar] }]`, + + ) + }) + + it('should handle computed property paths (\') within spread', () => { + testTransformation( + `[{ foo: ["...foo['bar']"] }]`, + `[{ foo: [...foo['bar']] }]`, + ) + }) + + it('should handle computed property paths (") within spread', () => { + testTransformation( + `[{ foo: ['...foo["bar"]'] }]`, + `[{ foo: [...foo["bar"]] }]`, + ) + }) + + it('should handle indexed paths in spread', () => { + testTransformation( + `[{ foo: ["...foo[0]"] }]`, + `[{ foo: [...foo[0]] }]`, + ) + }) +}) + +describe('buildTailwindThemeColorsFromCssVars', () => { + it('should inline color names', () => { + expect( + buildTailwindThemeColorsFromCssVars({ + 'primary': 'blue', + 'primary-light': 'skyblue', + 'primary-dark': 'navy', + 'secondary': 'green', + 'accent': 'orange', + 'accent-hover': 'darkorange', + 'accent-active': 'orangered', + }), + ).toEqual({ + primary: { + DEFAULT: 'hsl(var(--primary))', + light: 'hsl(var(--primary-light))', + dark: 'hsl(var(--primary-dark))', + }, + secondary: 'hsl(var(--secondary))', + accent: { + DEFAULT: 'hsl(var(--accent))', + hover: 'hsl(var(--accent-hover))', + active: 'hsl(var(--accent-active))', + }, + }) + }) + + it('should not add a DEFAULT if not present', () => { + expect( + buildTailwindThemeColorsFromCssVars({ + 'primary-light': 'skyblue', + 'primary-dark': 'navy', + 'secondary': 'green', + 'accent': 'orange', + 'accent-hover': 'darkorange', + 'accent-active': 'orangered', + }), + ).toEqual({ + primary: { + light: 'hsl(var(--primary-light))', + dark: 'hsl(var(--primary-dark))', + }, + secondary: 'hsl(var(--secondary))', + accent: { + DEFAULT: 'hsl(var(--accent))', + hover: 'hsl(var(--accent-hover))', + active: 'hsl(var(--accent-active))', + }, + }) + }) + + it('should build tailwind theme colors from css vars', () => { + expect( + buildTailwindThemeColorsFromCssVars({ + 'background': '0 0% 100%', + 'foreground': '224 71.4% 4.1%', + 'card': '0 0% 100%', + 'card-foreground': '224 71.4% 4.1%', + 'popover': '0 0% 100%', + 'popover-foreground': '224 71.4% 4.1%', + 'primary': '220.9 39.3% 11%', + 'primary-foreground': '210 20% 98%', + 'secondary': '220 14.3% 95.9%', + 'secondary-foreground': '220.9 39.3% 11%', + 'muted': '220 14.3% 95.9%', + 'muted-foreground': '220 8.9% 46.1%', + 'accent': '220 14.3% 95.9%', + 'accent-foreground': '220.9 39.3% 11%', + 'destructive': '0 84.2% 60.2%', + 'destructive-foreground': '210 20% 98%', + 'border': '220 13% 91%', + 'input': '220 13% 91%', + 'ring': '224 71.4% 4.1%', + }), + ).toEqual({ + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }) + }) +}) diff --git a/packages/cli/test/utils/updaters/update-tailwind-content.test.ts b/packages/cli/test/utils/updaters/update-tailwind-content.test.ts new file mode 100644 index 000000000..541d694b6 --- /dev/null +++ b/packages/cli/test/utils/updaters/update-tailwind-content.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' + +import { transformTailwindContent } from '../../../src/utils/updaters/update-tailwind-content' + +const SHARED_CONFIG = { + $schema: 'https://shadcn-vue.com/schema.json', + style: 'new-york', + rsc: true, + tsx: true, + tailwind: { + config: 'tailwind.config.ts', + css: 'app/globals.css', + baseColor: 'slate', + cssVariables: true, + }, + aliases: { + components: '@/components', + utils: '@/lib/utils', + }, + resolvedPaths: { + cwd: '.', + tailwindConfig: 'tailwind.config.ts', + tailwindCss: 'app/globals.css', + components: './components', + utils: './lib/utils', + ui: './components/ui', + }, +} + +describe('transformTailwindContent -> content property', () => { + it('should add content property if not in config', async () => { + expect( + await transformTailwindContent( + `import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + `, + ['./foo/**/*.{js,ts,jsx,tsx,mdx}', './bar/**/*.{js,ts,jsx,tsx,mdx}'], + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) + + it('should NOT add content property if already in config', async () => { + expect( + await transformTailwindContent( + `import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +} +export default config + `, + ['./app/**/*.{js,ts,jsx,tsx,mdx}', './bar/**/*.{js,ts,jsx,tsx,mdx}'], + { + config: SHARED_CONFIG, + }, + ), + ).toMatchSnapshot() + }) +}) diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index 47a0b3582..54208735b 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ clean: true, - dts: true, + dts: false, entry: ['src/index.ts'], format: ['esm'], sourcemap: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97350e403..551d4e15d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -246,6 +246,9 @@ importers: packages/cli: dependencies: + '@nuxt/kit': + specifier: ^3.14.159 + version: 3.14.159(magicast@0.3.5)(rollup@4.27.3) '@unovue/detypes': specifier: ^0.8.4 version: 0.8.4