feat: use unjs modules and improve cli from main shadcn-ui source, custom ui dir (#324)

* feat: add devDeps, add nypm for installing deps

* feat: custom ui dir

* refactor: use consola instead of chalk

* test: ui alias

* refactor: import { z } from 'zod' instead of *, replace node:path with pathe

* chore: add components name to `configFile` option

* chore: update `c12` which fix json5 parse issue

and it also supports .config directory

* chore: update `https-proxy-agent`

* fix: await until dependencies are installed then run detypes process

* feat: add tailwind prefix

* test: tw-prefix snapshot

* chore: add prefix option to init

* test: apply prefix

* fix: tw-prefix parse wrongly

* chore: hide prefix temporarily

---------

Co-authored-by: zernonia <zernonia@gmail.com>
This commit is contained in:
Sadegh Barati 2024-03-06 05:38:19 +03:30 committed by GitHub
parent c487137ac5
commit 0e84af73de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1036 additions and 945 deletions

View File

@ -4,7 +4,6 @@ description: Powerful table and datagrids built using TanStack Table.
primitive: https://tanstack.com/table/v8/docs/guide/introduction
---
<ComponentPreview name="DataTableDemo" />
## Introduction
@ -56,7 +55,6 @@ npm install @tanstack/vue-table
<ComponentPreview name="DataTableColumnPinningDemo" />
## Prerequisites
We are going to build a table to show recent payments. Here's what our data looks like:
@ -219,7 +217,6 @@ const table = useVueTable({
</Callout>
### Render the table
Finally, we'll render our table in our index component.
@ -270,7 +267,6 @@ Let's format the amount cell to display the dollar amount. We'll also align the
Update the `header` and `cell` definitions for amount as follows:
```ts:line-numbers title="components/payments/columns.ts" {5-17}
import { h } from 'vue'
@ -345,7 +341,6 @@ function copy(id: string) {
Update our columns definition to add a new `actions` column. The `actions` cell returns a `<Dropdown />` component.
```ts:line-numbers showLineNumber{2,6-16}
import { ColumnDef } from "@tanstack/vue-table"
import DropdownAction from '@/components/DataTableDropDown.vue'

View File

@ -42,6 +42,9 @@
},
"components": {
"type": "string"
},
"ui": {
"type": "string"
}
},
"required": ["utils", "components"]

View File

@ -24,6 +24,7 @@ export default antfu(
'no-tabs': 0,
'import/first': 0,
'node/prefer-global/process': 0,
'style/no-tabs': 0,
},
},
)

View File

@ -32,7 +32,7 @@
"dev": "tsup --watch",
"build": "tsup",
"typecheck": "tsc --noEmit",
"clean": "rimraf dist && rimraf components",
"clean": "node ./scripts/rimraf.js",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"start:dev": "COMPONENTS_REGISTRY_URL=http://localhost:3001 node dist/index.js",
@ -45,41 +45,39 @@
"test:ui": "vitest --ui"
},
"dependencies": {
"@antfu/ni": "^0.21.8",
"@babel/core": "^7.22.17",
"@babel/parser": "^7.22.16",
"@babel/plugin-transform-typescript": "^7.22.15",
"@babel/core": "^7.24.0",
"@babel/parser": "^7.24.0",
"@vue/compiler-sfc": "^3.4",
"chalk": "5.3.0",
"commander": "^11.0.0",
"cosmiconfig": "^8.3.6",
"c12": "^1.9.0",
"commander": "^12.0.0",
"consola": "^3.2.3",
"detype": "npm:detypes@^0.7.9",
"diff": "^5.1.0",
"execa": "^8.0.1",
"fs-extra": "^11.1.1",
"https-proxy-agent": "^7.0.2",
"diff": "^5.2.0",
"fs-extra": "^11.2.0",
"https-proxy-agent": "^7.0.4",
"lodash.template": "^4.5.0",
"magic-string": "^0.30.3",
"node-fetch": "^3.3.2",
"ora": "^7.0.1",
"magic-string": "^0.30.8",
"nypm": "^0.3.8",
"ofetch": "^1.3.3",
"ora": "^8.0.1",
"pathe": "^1.1.2",
"prompts": "^2.4.2",
"radix-vue": "^1.4.8",
"recast": "^0.23.4",
"rimraf": "^5.0.1",
"ts-morph": "^19.0.0",
"radix-vue": "^1.4.9",
"ts-morph": "^21.0.1",
"tsconfig-paths": "^4.2.0",
"vite-tsconfig-paths": "^4.2.1",
"zod": "^3.22.2"
"zod": "^3.22.4"
},
"devDependencies": {
"@types/babel__core": "^7.20.1",
"@types/diff": "^5.0.3",
"@types/fs-extra": "^11.0.1",
"@types/lodash.template": "^4.5.1",
"@types/prompts": "^2.4.4",
"@types/babel__core": "^7.20.5",
"@types/diff": "^5.0.9",
"@types/fs-extra": "^11.0.4",
"@types/lodash.template": "^4.5.3",
"@types/node": "^20.11.24",
"@types/prompts": "^2.4.9",
"@vitest/ui": "^0.34.4",
"tsup": "^7.2.0",
"type-fest": "^4.3.1",
"typescript": "^5.2.2"
"tsup": "^8.0.2",
"type-fest": "^4.10.3",
"typescript": "^5.3.3",
"vite-tsconfig-paths": "^4.3.1"
}
}

View File

@ -0,0 +1,10 @@
import fsp from 'node:fs/promises'
function rmdir(dirs) {
dirs.forEach(async (dir) => {
await fsp.unlink(dir).catch(() => {})
await fsp.rm(dir, { recursive: true, force: true }).catch(() => {})
})
}
rmdir(['dist', 'components'])

View File

@ -1,17 +1,16 @@
import { existsSync, promises as fs, rmSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import chalk from 'chalk'
import path from 'pathe'
import { consola } from 'consola'
import { colors } from 'consola/utils'
import { Command } from 'commander'
import { execa } from 'execa'
import ora from 'ora'
import prompts from 'prompts'
import * as z from 'zod'
import { z } from 'zod'
import { addDependency, addDevDependency } from 'nypm'
import { transform } from '@/src/utils/transformers'
import { getConfig } from '@/src/utils/get-config'
import { getPackageManager } from '@/src/utils/get-package-manager'
import { handleError } from '@/src/utils/handle-error'
import { logger } from '@/src/utils/logger'
import {
fetchTree,
getItemTargetPath,
@ -52,15 +51,15 @@ export const add = new Command()
const cwd = path.resolve(options.cwd)
if (!existsSync(cwd)) {
logger.error(`The path ${cwd} does not exist. Please try again.`)
consola.error(`The path ${cwd} does not exist. Please try again.`)
process.exit(1)
}
const config = await getConfig(cwd)
if (!config) {
logger.warn(
`Configuration is missing. Please run ${chalk.green('init')} to create a components.json file.`,
)
consola.warn(`Configuration is missing. Please run ${colors.green('init')} to create a components.json file.`)
process.exit(1)
}
@ -88,7 +87,7 @@ export const add = new Command()
}
if (!selectedComponents?.length) {
logger.warn('No components selected. Exiting.')
consola.warn('No components selected. Exiting.')
process.exit(0)
}
@ -97,7 +96,7 @@ export const add = new Command()
const baseColor = await getRegistryBaseColor(config.tailwind.baseColor)
if (!payload.length) {
logger.warn('Selected components not found. Exiting.')
consola.warn('Selected components not found. Exiting.')
process.exit(0)
}
@ -114,7 +113,6 @@ export const add = new Command()
}
const spinner = ora('Installing components...').start()
const skippedDeps = new Set<string>()
for (const item of payload) {
spinner.text = `Installing ${item.name}...`
const targetDir = getItemTargetPath(
@ -144,8 +142,8 @@ export const add = new Command()
})
if (!overwrite) {
logger.info(
`Skipped ${item.name}. To overwrite, run with the ${chalk.green(
consola.info(
`Skipped ${item.name}. To overwrite, run with the ${colors.green(
'--overwrite',
)} flag.`,
)
@ -159,6 +157,20 @@ export const add = new Command()
}
}
// Install dependencies.
await Promise.allSettled(
[
item.dependencies?.length && await addDependency(item.dependencies, {
cwd,
silent: true,
}),
item.devDependencies?.length && await addDevDependency(item.devDependencies, {
cwd,
silent: true,
}),
],
)
const componentDir = path.resolve(targetDir, item.name)
if (!existsSync(componentDir))
await fs.mkdir(componentDir, { recursive: true })
@ -201,25 +213,6 @@ export const add = new Command()
await fs.writeFile(filePath, content)
}
// Install dependencies.
if (item.dependencies?.length) {
item.dependencies.forEach(dep =>
skippedDeps.add(dep),
)
const packageManager = await getPackageManager(cwd)
await execa(
packageManager,
[
packageManager === 'npm' ? 'install' : 'add',
...item.dependencies,
],
{
cwd,
},
)
}
}
spinner.succeed('Done.')
}

View File

@ -1,14 +1,14 @@
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import chalk from 'chalk'
import path from 'pathe'
import { consola } from 'consola'
import { colors } from 'consola/utils'
import { Command } from 'commander'
import { type Change, diffLines } from 'diff'
import * as z from 'zod'
import { z } from 'zod'
import type { Config } from '@/src/utils/get-config'
import { getConfig } from '@/src/utils/get-config'
import { handleError } from '@/src/utils/handle-error'
import { logger } from '@/src/utils/logger'
import {
fetchTree,
getItemTargetPath,
@ -45,14 +45,14 @@ export const diff = new Command()
const cwd = path.resolve(options.cwd)
if (!existsSync(cwd)) {
logger.error(`The path ${cwd} does not exist. Please try again.`)
consola.error(`The path ${cwd} does not exist. Please try again.`)
process.exit(1)
}
const config = await getConfig(cwd)
if (!config) {
logger.warn(
`Configuration is missing. Please run ${chalk.green(
consola.warn(
`Configuration is missing. Please run ${colors.green(
'init',
)} to create a components.json file.`,
)
@ -88,19 +88,20 @@ export const diff = new Command()
}
if (!componentsWithUpdates.length) {
logger.info('No updates found.')
consola.info('No updates found.')
process.exit(0)
}
logger.info('The following components have updates available:')
consola.info('The following components have updates available:')
for (const component of componentsWithUpdates) {
logger.info(`- ${component.name}`)
consola.info(`- ${component.name}`)
for (const change of component.changes)
logger.info(` - ${change.filePath}`)
consola.info(` - ${change.filePath}`)
}
logger.break()
logger.info(
`Run ${chalk.green('diff <component>')} to see the changes.`,
consola.log('')
consola.info(
`Run ${colors.green('diff <component>')} to see the changes.`,
)
process.exit(0)
}
@ -111,8 +112,8 @@ export const diff = new Command()
)
if (!component) {
logger.error(
`The component ${chalk.green(options.component)} does not exist.`,
consola.error(
`The component ${colors.green(options.component)} does not exist.`,
)
process.exit(1)
}
@ -120,14 +121,14 @@ export const diff = new Command()
const changes = await diffComponent(component, config)
if (!changes.length) {
logger.info(`No updates found for ${options.component}.`)
consola.info(`No updates found for ${options.component}.`)
process.exit(0)
}
for (const change of changes) {
logger.info(`- ${change.filePath}`)
consola.info(`- ${change.filePath}`)
printDiff(change.patch)
logger.info('')
consola.log('')
}
}
catch (error) {
@ -184,10 +185,10 @@ function printDiff(diff: Change[]) {
diff.forEach((part) => {
if (part) {
if (part.added)
return process.stdout.write(chalk.green(part.value))
return process.stdout.write(colors.green(part.value))
if (part.removed)
return process.stdout.write(chalk.red(part.value))
return process.stdout.write(colors.red(part.value))
return process.stdout.write(part.value)
}

View File

@ -1,22 +1,21 @@
import { existsSync, promises as fs } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import chalk from 'chalk'
import path from 'pathe'
import { Command } from 'commander'
import { execa } from 'execa'
import template from 'lodash.template'
import ora from 'ora'
import prompts from 'prompts'
import * as z from 'zod'
import { z } from 'zod'
import { addDependency, addDevDependency } from 'nypm'
import { consola } from 'consola'
import { colors } from 'consola/utils'
import * as templates from '../utils/templates'
import {
getRegistryBaseColor,
getRegistryBaseColors,
getRegistryStyles,
} from '../utils/registry'
import { logger } from '../utils/logger'
import { handleError } from '../utils/handle-error'
import { getPackageManager } from '../utils/get-package-manager'
import { transformByDetype } from '../utils/transformers/transform-sfc'
import {
type Config,
@ -29,6 +28,7 @@ import {
resolveConfigPaths,
} from '../utils/get-config'
import { transformCJSToESM } from '../utils/transformers/transform-cjs-to-esm'
import { applyPrefixesCss } from '../utils/transformers/transform-tw-prefix'
const PROJECT_DEPENDENCIES = {
base: [
@ -64,7 +64,7 @@ export const init = new Command()
// Ensure target directory exists.
if (!existsSync(cwd)) {
logger.error(`The path ${cwd} does not exist. Please try again.`)
consola.error(`The path ${cwd} does not exist. Please try again.`)
process.exit(1)
}
@ -74,11 +74,11 @@ export const init = new Command()
await runInit(cwd, config)
logger.info('')
logger.info(
`${chalk.green('Success!')} Project initialization completed.`,
consola.log('')
consola.info(
`${colors.green('Success!')} Project initialization completed.`,
)
logger.info('')
consola.log('')
}
catch (error) {
handleError(error)
@ -90,7 +90,7 @@ export async function promptForConfig(
defaultConfig: Config | null = null,
skip = false,
) {
const highlight = (text: string) => chalk.cyan(text)
const highlight = (text: string) => colors.cyan(text)
const styles = await getRegistryStyles()
const baseColors = await getRegistryBaseColors()
@ -151,6 +151,14 @@ export async function promptForConfig(
active: 'yes',
inactive: 'no',
},
// {
// type: 'text',
// name: 'tailwindPrefix',
// message: `Are you using a custom ${highlight(
// 'tailwind prefix eg. tw-',
// )}? (Leave blank if not)`,
// initial: '',
// },
{
type: 'text',
name: 'tailwindConfig',
@ -187,6 +195,7 @@ export async function promptForConfig(
css: options.tailwindCss,
baseColor: options.tailwindBaseColor,
cssVariables: options.tailwindCssVariables,
// prefix: options.tailwindPrefix,
},
aliases: {
utils: options.utils,
@ -207,7 +216,7 @@ export async function promptForConfig(
}
// Write to file.
logger.info('')
consola.log('')
const spinner = ora('Writing components.json...').start()
const targetPath = path.resolve(cwd, 'components.json')
await fs.writeFile(targetPath, JSON.stringify(config, null, 2), 'utf8')
@ -247,8 +256,8 @@ export async function runInit(cwd: string, config: Config) {
transformCJSToESM(
config.resolvedPaths.tailwindConfig,
config.tailwind.cssVariables
? template(templates.TAILWIND_CONFIG_WITH_VARIABLES)({ extension, framework: config.framework })
: template(templates.TAILWIND_CONFIG)({ extension, framework: config.framework }),
? template(templates.TAILWIND_CONFIG_WITH_VARIABLES)({ extension, framework: config.framework, prefix: config.tailwind.prefix })
: template(templates.TAILWIND_CONFIG)({ extension, framework: config.framework, prefix: config.tailwind.prefix }),
),
'utf8',
)
@ -259,7 +268,9 @@ export async function runInit(cwd: string, config: Config) {
await fs.writeFile(
config.resolvedPaths.tailwindCss,
config.tailwind.cssVariables
? baseColor.cssVarsTemplate
? config.tailwind.prefix
? applyPrefixesCss(baseColor.cssVarsTemplate, config.tailwind.prefix)
: baseColor.cssVarsTemplate
: baseColor.inlineColorsTemplate,
'utf8',
)
@ -276,20 +287,29 @@ export async function runInit(cwd: string, config: Config) {
// Install dependencies.
const dependenciesSpinner = ora('Installing dependencies...')?.start()
const packageManager = await getPackageManager(cwd)
const deps = PROJECT_DEPENDENCIES.base.concat(
config.framework === 'nuxt' ? PROJECT_DEPENDENCIES.nuxt : [],
).concat(
config.style === 'new-york' ? ['@radix-icons/vue'] : ['lucide-vue-next'],
).filter(Boolean)
await execa(
packageManager,
[packageManager === 'npm' ? 'install' : 'add', ...deps],
{
cwd,
},
async function addNuxtDevDeps() {
if (config.framework === 'nuxt') {
await addDevDependency(PROJECT_DEPENDENCIES.nuxt, {
cwd,
silent: true,
})
}
}
await Promise.allSettled(
[
addNuxtDevDeps(),
addDependency(deps, {
cwd,
silent: true,
}),
],
)
dependenciesSpinner?.succeed()
}

View File

@ -1,9 +1,9 @@
import path from 'node:path'
import { existsSync } from 'node:fs'
import { cosmiconfig } from 'cosmiconfig'
import path from 'pathe'
import { loadConfig as c12LoadConfig } from 'c12'
import type { ConfigLoaderResult } from 'tsconfig-paths'
import { loadConfig } from 'tsconfig-paths'
import * as z from 'zod'
import { z } from 'zod'
import { resolveImport } from '@/src/utils/resolve-import'
export const DEFAULT_STYLE = 'default'
@ -19,12 +19,6 @@ export const TAILWIND_CSS_PATH = {
astro: 'src/styles/globals.css',
}
// TODO: Figure out if we want to support all cosmiconfig formats.
// A simple components.json file would be nice.
const explorer = cosmiconfig('components', {
searchPlaces: ['components.json'],
})
export const rawConfigSchema = z
.object({
$schema: z.string().optional(),
@ -35,11 +29,13 @@ export const rawConfigSchema = z
css: z.string(),
baseColor: z.string(),
cssVariables: z.boolean().default(true),
prefix: z.string().optional(),
}),
framework: z.string().default('Vite'),
aliases: z.object({
components: z.string(),
utils: z.string(),
ui: z.string().default('').optional(),
}),
})
.strict()
@ -53,6 +49,7 @@ export const configSchema = rawConfigSchema
tailwindCss: z.string(),
utils: z.string(),
components: z.string(),
ui: z.string(),
}),
})
@ -103,15 +100,22 @@ export async function resolveConfigPaths(cwd: string, config: RawConfig) {
tailwindCss: path.resolve(cwd, config.tailwind.css),
utils: resolveImport(config.aliases.utils, tsConfig),
components: resolveImport(config.aliases.components, tsConfig),
ui: config.aliases.ui
? resolveImport(config.aliases.ui, tsConfig)
: resolveImport(config.aliases.components, tsConfig),
},
})
}
export async function getRawConfig(cwd: string): Promise<RawConfig | null> {
try {
const configResult = await explorer.search(cwd)
const configResult = await c12LoadConfig({
name: 'components',
configFile: 'components',
cwd,
})
if (!configResult)
if (!configResult.config || Object.keys(configResult.config).length === 0)
return null
return rawConfigSchema.parse(configResult.config)

View File

@ -1,5 +1,5 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import path from 'pathe'
import fs from 'fs-extra'
import { type PackageJson } from 'type-fest'

View File

@ -1,16 +0,0 @@
import { detect } from '@antfu/ni'
export async function getPackageManager(
targetDir: string,
): Promise<'yarn' | 'pnpm' | 'bun' | 'npm'> {
const packageManager = await detect({ programmatic: true, cwd: targetDir })
if (packageManager === 'yarn@berry')
return 'yarn'
if (packageManager === 'pnpm@6')
return 'pnpm'
if (packageManager === 'bun')
return 'bun'
return packageManager ?? 'npm'
}

View File

@ -1,5 +1,5 @@
import { existsSync } from 'node:fs'
import path from 'node:path'
import path from 'pathe'
import fs from 'fs-extra'
export async function getProjectInfo() {

View File

@ -1,16 +1,16 @@
import { logger } from './logger'
import { consola } from 'consola'
export function handleError(error: unknown) {
if (typeof error === 'string') {
logger.error(error)
consola.error(error)
process.exit(1)
}
if (error instanceof Error) {
logger.error(error.message)
consola.error(error.message)
process.exit(1)
}
logger.error('Something went wrong. Please try again.')
consola.error('Something went wrong. Please try again.')
process.exit(1)
}

View File

@ -1,19 +0,0 @@
import chalk from 'chalk'
export const logger = {
error(...args: unknown[]) {
console.log(chalk.red(...args))
},
warn(...args: unknown[]) {
console.log(chalk.yellow(...args))
},
info(...args: unknown[]) {
console.log(chalk.cyan(...args))
},
success(...args: unknown[]) {
console.log(chalk.green(...args))
},
break() {
console.log('')
},
}

View File

@ -1,8 +1,9 @@
import path from 'node:path'
import process from 'node:process'
import path from 'pathe'
import { HttpsProxyAgent } from 'https-proxy-agent'
import fetch from 'node-fetch'
import { ofetch } from 'ofetch'
import type * as z from 'zod'
import consola from 'consola'
import {
registryBaseColorSchema,
registryIndexSchema,
@ -122,9 +123,12 @@ export function getItemTargetPath(
override?: string,
) {
// Allow overrides for all items but ui.
if (override && item.type !== 'components:ui')
if (override)
return override
if (item.type === 'components:ui' && config.aliases.ui)
return config.resolvedPaths.ui
const [parent, type] = item.type.split(':')
if (!(parent in config.resolvedPaths))
return null
@ -139,17 +143,18 @@ async function fetchRegistry(paths: string[]) {
try {
const results = await Promise.all(
paths.map(async (path) => {
const response = await fetch(`${baseUrl}/registry/${path}`, {
const response = await ofetch(`${baseUrl}/registry/${path}`, {
// @ts-expect-error agent type
agent,
})
return await response.json()
return response
}),
)
return results
}
catch (error) {
// eslint-disable-next-line no-console
console.log(error)
consola.error(error)
throw new Error(`Failed to fetch registry from ${baseUrl}.`)
}
}

View File

@ -1,9 +1,10 @@
import * as z from 'zod'
import { z } from 'zod'
// TODO: Extract this to a shared package.
export const registryItemSchema = z.object({
name: z.string(),
dependencies: z.array(z.string()).optional(),
devDependencies: z.array(z.string()).optional(),
registryDependencies: z.array(z.string()).optional(),
files: z.array(z.string()),
type: z.enum(['components:ui', 'components:component', 'components:example']),

View File

@ -17,6 +17,7 @@ module.exports = {
'./app/**/*.{<%- extension %>,<%- extension %>x,vue}',
'./src/**/*.{<%- extension %>,<%- extension %>x,vue}',
],
prefix: "<%- prefix %>",
theme: {
container: {
center: true,
@ -51,6 +52,7 @@ export const TAILWIND_CONFIG_WITH_VARIABLES = `const animate = require("tailwind
module.exports = {
darkMode: ["class"],
safelist: ["dark"],
prefix: "<%- prefix %>",
<% if (framework === 'vite') { %>
content: [
'./pages/**/*.{<%- extension %>,<%- extension %>x,vue}',

View File

@ -1,12 +1,13 @@
import { promises as fs } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import path from 'pathe'
import { Project, ScriptKind, type SourceFile } from 'ts-morph'
import type * as z from 'zod'
import type { Config } from '@/src/utils/get-config'
import type { registryBaseColorSchema } from '@/src/utils/registry/schema'
import { transformCssVars } from '@/src/utils/transformers/transform-css-vars'
import { transformImport } from '@/src/utils/transformers/transform-import'
import { transformTwPrefixes } from '@/src/utils/transformers/transform-tw-prefix'
import { transformSFC } from '@/src/utils/transformers/transform-sfc'
export interface TransformOpts {
@ -25,6 +26,7 @@ export type Transformer<Output = SourceFile> = (
const transformers: Transformer[] = [
transformCssVars,
transformImport,
// transformTwPrefixes,
]
const project = new Project({

View File

@ -88,29 +88,28 @@ export function applyColorMapping(
if (input.includes(' border '))
input = input.replace(' border ', ' border border-border ')
// Build color mappings.
const classNames = input.split(' ')
const lightMode: string[] = []
const darkMode: string[] = []
const lightMode = new Set<string>()
const darkMode = new Set<string>()
for (const className of classNames) {
const [variant, value, modifier] = splitClassName(className)
const prefix = PREFIXES.find(prefix => value?.startsWith(prefix))
if (!prefix) {
if (!lightMode.includes(className))
lightMode.push(className)
if (!lightMode.has(className))
lightMode.add(className)
continue
}
const needle = value?.replace(prefix, '')
if (needle && needle in mapping.light) {
lightMode.push(
lightMode.add(
[variant, `${prefix}${mapping.light[needle]}`]
.filter(Boolean)
.join(':') + (modifier ? `/${modifier}` : ''),
)
darkMode.push(
darkMode.add(
['dark', variant, `${prefix}${mapping.dark[needle]}`]
.filter(Boolean)
.join(':') + (modifier ? `/${modifier}` : ''),
@ -118,9 +117,9 @@ export function applyColorMapping(
continue
}
if (!lightMode.includes(className))
lightMode.push(className)
if (!lightMode.has(className))
lightMode.add(className)
}
const combined = `${lightMode.join(' ').replace(/\'/g, '')} ${darkMode.join(' ').trim()}`.trim()
return `${combined}`
return [...Array.from(lightMode), ...Array.from(darkMode)].join(' ').trim()
}

View File

@ -8,12 +8,19 @@ export const transformImport: Transformer = async ({ sourceFile, config }) => {
// Replace @/lib/registry/[style] with the components alias.
if (moduleSpecifier.startsWith('@/lib/registry/')) {
importDeclaration.setModuleSpecifier(
moduleSpecifier.replace(
/^@\/lib\/registry\/[^/]+/,
config.aliases.components,
),
)
if (config.aliases.ui) {
importDeclaration.setModuleSpecifier(
moduleSpecifier.replace(/^@\/lib\/registry\/[^/]+\/ui/, config.aliases.ui),
)
}
else {
importDeclaration.setModuleSpecifier(
moduleSpecifier.replace(
/^@\/lib\/registry\/[^/]+/,
config.aliases.components,
),
)
}
}
// Replace `import { cn } from "@/lib/utils"`

View File

@ -0,0 +1,80 @@
import { SyntaxKind } from 'ts-morph'
import { MagicString, parse } from '@vue/compiler-sfc'
import type { SFCTemplateBlock } from '@vue/compiler-sfc'
import { splitClassName } from './transform-css-vars'
import type { Transformer } from '@/src/utils/transformers'
export const transformTwPrefixes: Transformer = async ({
sourceFile,
config,
}) => {
const isVueFile = sourceFile.getFilePath().endsWith('vue')
if (!config.tailwind?.prefix)
return sourceFile
let template: SFCTemplateBlock | null = null
if (isVueFile) {
const parsed = parse(sourceFile.getText())
template = parsed.descriptor.template
if (!template)
return sourceFile
}
sourceFile.getDescendantsOfKind(SyntaxKind.StringLiteral).forEach((node) => {
if (template && template.loc.start.offset >= node.getPos())
return sourceFile
const attrName = sourceFile.getDescendantAtPos(node.getPos() - 2)?.getText()
if (isVueFile && attrName !== 'class')
return sourceFile
const value = node.getText()
const hasClosingDoubleQuote = value.match(/"/g)?.length === 2
if (value.search('\'') === -1 && hasClosingDoubleQuote) {
const mapped = applyPrefix(value.replace(/"/g, ''), config.tailwind.prefix)
node.replaceWithText(`"${mapped}"`)
}
else {
const s = new MagicString(value)
s.replace(/'(.*?)'/g, (substring) => {
return `'${applyPrefix(substring.replace(/\'/g, ''), config.tailwind.prefix)}'`
})
node.replaceWithText(s.toString())
}
})
return sourceFile
}
export function applyPrefix(input: string, prefix: string = '') {
const classNames = input.split(' ')
const prefixed: string[] = []
for (const className of classNames) {
const [variant, value, modifier] = splitClassName(className)
if (variant) {
modifier
? prefixed.push(`${variant}:${prefix}${value}/${modifier}`)
: prefixed.push(`${variant}:${prefix}${value}`)
}
else {
modifier
? prefixed.push(`${prefix}${value}/${modifier}`)
: prefixed.push(`${prefix}${value}`)
}
}
return prefixed.join(' ')
}
export function applyPrefixesCss(css: string, prefix: string) {
const lines = css.split('\n')
for (const line of lines) {
if (line.includes('@apply')) {
const originalTWCls = line.replace('@apply', '').trim()
const prefixedTwCls = applyPrefix(originalTWCls, prefix)
css = css.replace(originalTWCls, prefixedTwCls)
}
}
return css
}

View File

@ -1,14 +1,13 @@
import fs from 'node:fs'
import path from 'node:path'
import { execa } from 'execa'
import path from 'pathe'
import { addDependency, addDevDependency } from 'nypm'
import { afterEach, expect, test, vi } from 'vitest'
import { runInit } from '../../src/commands/init'
import { getConfig } from '../../src/utils/get-config'
import * as getPackageManger from '../../src/utils/get-package-manager'
import * as registry from '../../src/utils/registry'
vi.mock('execa')
vi.mock('nypm')
vi.mock('fs/promises', () => ({
writeFile: vi.fn(),
mkdir: vi.fn(),
@ -16,7 +15,6 @@ vi.mock('fs/promises', () => ({
vi.mock('ora')
test('init config-full', async () => {
vi.spyOn(getPackageManger, 'getPackageManager').mockResolvedValue('pnpm')
vi.spyOn(registry, 'getRegistryBaseColor').mockResolvedValue({
inlineColors: {},
cssVars: {},
@ -67,10 +65,8 @@ test('init config-full', async () => {
expect.stringContaining("import { type ClassValue, clsx } from 'clsx'"),
'utf8',
)
expect(execa).toHaveBeenCalledWith(
'pnpm',
expect(addDependency).toHaveBeenCalledWith(
[
'add',
'tailwindcss-animate',
'class-variance-authority',
'clsx',
@ -80,6 +76,7 @@ test('init config-full', async () => {
],
{
cwd: targetDir,
silent: true,
},
)
@ -88,7 +85,6 @@ test('init config-full', async () => {
})
test('init config-partial', async () => {
vi.spyOn(getPackageManger, 'getPackageManager').mockResolvedValue('npm')
vi.spyOn(registry, 'getRegistryBaseColor').mockResolvedValue({
inlineColors: {},
cssVars: {},
@ -139,10 +135,8 @@ test('init config-partial', async () => {
expect.stringContaining("import { type ClassValue, clsx } from 'clsx'"),
'utf8',
)
expect(execa).toHaveBeenCalledWith(
'npm',
expect(addDependency).toHaveBeenCalledWith(
[
'install',
'tailwindcss-animate',
'class-variance-authority',
'clsx',
@ -152,6 +146,7 @@ test('init config-partial', async () => {
],
{
cwd: targetDir,
silent: true,
},
)

View File

@ -4,10 +4,12 @@
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true
"cssVariables": true,
"prefix": "tw-"
},
"aliases": {
"utils": "~/lib/utils",
"components": "~/components"
"components": "~/components",
"ui": "~/ui"
}
}

View File

@ -12,6 +12,7 @@ export default {
'./app/**/*.{<%- extension %>,<%- extension %>x,vue}',
'./src/**/*.{<%- extension %>,<%- extension %>x,vue}',
],
prefix: \\"<%- prefix %>\\",
theme: {
container: {
center: true,
@ -48,6 +49,7 @@ exports[`handle tailwind config template correctly 2`] = `
export default {
darkMode: [\\"class\\"],
safelist: [\\"dark\\"],
prefix: \\"<%- prefix %>\\",
<% if (framework === 'vite') { %>
content: [
'./pages/**/*.{<%- extension %>,<%- extension %>x,vue}',

View File

@ -0,0 +1,127 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`transform tailwind prefix 1`] = `
"const testVariants = cva(
\\"tw-bg-background hover:tw-bg-muted tw-text-primary-foreground sm:focus:tw-text-accent-foreground\\",
{
variants: {
variant: {
default:
\\"tw-bg-primary tw-text-primary-foreground hover:tw-bg-primary/90\\",
},
size: {
default: \\"tw-h-10 tw-px-4 tw-py-2\\",
},
},
}
);
"
`;
exports[`transform tailwind prefix 2`] = `
"<template>
<div class=\\"tw-bg-background hover:tw-bg-muted tw-text-primary-foreground sm:focus:tw-text-accent-foreground\\">
foo
</div>
</template>
"
`;
exports[`transform tailwind prefix 3`] = `
"<template>
<div class=\\"tw-bg-white hover:tw-bg-stone-100 tw-text-stone-50 sm:focus:tw-text-stone-900 dark:tw-bg-stone-950 dark:hover:tw-bg-stone-800 dark:tw-text-stone-900 dark:sm:focus:tw-text-stone-50\\">
foo
</div>
</template>
"
`;
exports[`transform tailwind prefix 4`] = `
"<template>
<div id=\\"testing\\" v-bind=\\"props\\" @click=\\"handleSomething\\" :data-test=\\"true\\" :class=\\"cn('tw-bg-white hover:tw-bg-stone-100 dark:tw-bg-stone-950 dark:hover:tw-bg-stone-800', true && 'tw-text-stone-50 sm:focus:tw-text-stone-900 dark:tw-text-stone-900 dark:sm:focus:tw-text-stone-50')\\">
foo
</div>
</template>
"
`;
exports[`transform tailwind prefix 5`] = `
"@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--primary: 220.9 39.3% 11%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--ring: 217.9 10.6% 64.9%;
--radius: 0.5rem;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--primary: 210 20% 98%;
--primary-foreground: 220.9 39.3% 11%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 85.7% 97.3%;
--ring: 215 27.9% 16.9%;
}
}
@layer base {
* {
@apply tw-border-border;
}
body {
@apply tw-bg-background tw-text-foreground;
}
}"
`;

View File

@ -1,4 +1,4 @@
import { describe, expect, test } from 'vitest'
import { describe, expect, it } from 'vitest'
import {
applyColorMapping,
@ -7,7 +7,7 @@ import {
import baseColor from '../fixtures/colors/slate.json'
describe('split class', () => {
test.each([
it.each([
{
input: 'bg-popover',
output: [null, 'bg-popover', null],
@ -50,7 +50,7 @@ describe('split class', () => {
})
describe('apply color mapping', async () => {
test.each([
it.each([
{
input: 'bg-background text-foreground',
output: 'bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50',
@ -64,7 +64,7 @@ describe('apply color mapping', async () => {
input:
'text-destructive border-destructive/50 dark:border-destructive [&>svg]:text-destructive text-destructive',
output:
'text-red-500 border-red-500/50 dark:border-red-500 [&>svg]:text-red-500 text-red-500 dark:text-red-900 dark:border-red-900/50 dark:dark:border-red-900 dark:[&>svg]:text-red-900 dark:text-red-900',
'text-red-500 border-red-500/50 dark:border-red-500 [&>svg]:text-red-500 dark:text-red-900 dark:border-red-900/50 dark:dark:border-red-900 dark:[&>svg]:text-red-900',
},
{
input:

View File

@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { applyPrefix } from '../../src/utils/transformers/transform-tw-prefix'
describe('apply tailwind prefix', () => {
it.each([
{
input: 'bg-slate-800 text-gray-500',
output: 'tw-bg-slate-800 tw-text-gray-500',
},
{
input: 'hover:dark:bg-background dark:text-foreground',
output: 'hover:dark:tw-bg-background dark:tw-text-foreground',
},
{
input:
'rounded-lg border border-slate-200 bg-white text-slate-950 shadow-sm dark:border-slate-800 dark:bg-slate-950 dark:text-slate-50',
output:
'tw-rounded-lg tw-border tw-border-slate-200 tw-bg-white tw-text-slate-950 tw-shadow-sm dark:tw-border-slate-800 dark:tw-bg-slate-950 dark:tw-text-slate-50',
},
{
input:
'text-red-500 border-red-500/50 dark:border-red-500 [&>svg]:text-red-500 text-red-500 dark:text-red-900 dark:border-red-900/50 dark:dark:border-red-900 dark:[&>svg]:text-red-900 dark:text-red-900',
output:
'tw-text-red-500 tw-border-red-500/50 dark:tw-border-red-500 [&>svg]:tw-text-red-500 tw-text-red-500 dark:tw-text-red-900 dark:tw-border-red-900/50 dark:dark:tw-border-red-900 dark:[&>svg]:tw-text-red-900 dark:tw-text-red-900',
},
{
input:
'flex h-full w-full items-center justify-center rounded-full bg-muted',
output:
'tw-flex tw-h-full tw-w-full tw-items-center tw-justify-center tw-rounded-full tw-bg-muted',
},
{
input:
'absolute right-4 top-4 bg-primary rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary',
output:
'tw-absolute tw-right-4 tw-top-4 tw-bg-primary tw-rounded-sm tw-opacity-70 tw-ring-offset-background tw-transition-opacity hover:tw-opacity-100 focus:tw-outline-none focus:tw-ring-2 focus:tw-ring-ring focus:tw-ring-offset-2 disabled:tw-pointer-events-none data-[state=open]:tw-bg-secondary',
},
])(`applyTwPrefix($input) -> $output`, ({ input, output }) => {
expect(applyPrefix(input, 'tw-')).toBe(output)
})
})

View File

@ -1,9 +1,9 @@
import path from 'node:path'
import { expect, test } from 'vitest'
import path from 'pathe'
import { expect, it } from 'vitest'
import { getConfig, getRawConfig } from '../../src/utils/get-config'
test('get raw config', async () => {
it('get raw config', async () => {
expect(
await getRawConfig(path.resolve(__dirname, '../fixtures/config-none')),
).toEqual(null)
@ -31,7 +31,7 @@ test('get raw config', async () => {
).rejects.toThrowError()
})
test('get config', async () => {
it('get config', async () => {
expect(
await getConfig(path.resolve(__dirname, '../fixtures/config-none')),
).toEqual(null)
@ -71,6 +71,11 @@ test('get config', async () => {
'../fixtures/config-partial',
'./components',
),
ui: path.resolve(
__dirname,
'../fixtures/config-partial',
'./components',
),
utils: path.resolve(
__dirname,
'../fixtures/config-partial',
@ -89,9 +94,11 @@ test('get config', async () => {
baseColor: 'zinc',
css: 'src/app/globals.css',
cssVariables: true,
prefix: 'tw-',
},
aliases: {
components: '~/components',
ui: '~/ui',
utils: '~/lib/utils',
},
framework: 'Vite',
@ -111,6 +118,11 @@ test('get config', async () => {
'../fixtures/config-full',
'./src/components',
),
ui: path.resolve(
__dirname,
'../fixtures/config-full',
'./src/ui',
),
utils: path.resolve(
__dirname,
'../fixtures/config-full',
@ -152,6 +164,11 @@ test('get config', async () => {
'../fixtures/config-js',
'./components',
),
ui: path.resolve(
__dirname,
'../fixtures/config-js',
'./components',
),
utils: path.resolve(__dirname, '../fixtures/config-js', './lib/utils'),
},
})

View File

@ -1,26 +0,0 @@
import path from 'node:path'
import { expect, test } from 'vitest'
import { getPackageManager } from '../../src/utils/get-package-manager'
test('get package manager', async () => {
expect(
await getPackageManager(path.resolve(__dirname, '../fixtures/project-yarn')),
).toBe('yarn')
expect(
await getPackageManager(path.resolve(__dirname, '../fixtures/project-npm')),
).toBe('npm')
expect(
await getPackageManager(path.resolve(__dirname, '../fixtures/project-pnpm')),
).toBe('pnpm')
expect(
await getPackageManager(path.resolve(__dirname, '../fixtures/project-bun')),
).toBe('bun')
expect(
await getPackageManager(path.resolve(__dirname, '../fixtures/next')),
).toBe('pnpm')
})

View File

@ -1,4 +1,4 @@
import path from 'node:path'
import path from 'pathe'
import { type ConfigLoaderSuccessResult, loadConfig } from 'tsconfig-paths'
import { expect, test } from 'vitest'

View File

@ -1,9 +1,9 @@
import { expect, test } from 'vitest'
import { expect, it } from 'vitest'
import { transform } from '../../src/utils/transformers'
import stone from '../fixtures/colors/stone.json'
test('transform css vars', async () => {
it('transform css vars', async () => {
expect(
await transform({
filename: 'app.vue',

View File

@ -1,4 +1,4 @@
import { resolve } from 'node:path'
import { resolve } from 'pathe'
import { describe, expect, test } from 'vitest'
import { transform } from '../../src/utils/transformers'

View File

@ -0,0 +1,115 @@
import { expect, it } from 'vitest'
import { transform } from '../../src/utils/transformers'
import { applyPrefixesCss } from '../../src/utils/transformers/transform-tw-prefix'
import stone from '../fixtures/colors/stone.json'
it('transform tailwind prefix', async () => {
// expect(
// await transform({
// filename: 'test.ts',
// raw: `const testVariants = cva(
// 'bg-background hover:bg-muted text-primary-foreground sm:focus:text-accent-foreground',
// {
// variants: {
// variant: {
// default: 'bg-primary text-primary-foreground hover:bg-primary/90',
// },
// size: {
// default: 'h-10 px-4 py-2',
// },
// },
// },
// )`,
// config: {
// tailwind: {
// baseColor: 'stone',
// prefix: 'tw-',
// },
// aliases: {
// components: '@/components',
// utils: '@/lib/utils',
// },
// },
// baseColor: 'stone',
// }),
// ).toMatchSnapshot()
// expect(
// await transform({
// filename: 'app.vue',
// raw: `<template>
// <div class="bg-background hover:bg-muted text-primary-foreground sm:focus:text-accent-foreground">
// foo
// </div>
// </template>
// `,
// config: {
// tailwind: {
// baseColor: 'stone',
// prefix: 'tw-',
// },
// aliases: {
// components: '@/components',
// utils: '@/lib/utils',
// },
// },
// baseColor: 'stone',
// }),
// ).toMatchSnapshot()
// expect(
// await transform({
// filename: 'app.vue',
// raw: `<template>
// <div class="bg-background hover:bg-muted text-primary-foreground sm:focus:text-accent-foreground">
// foo
// </div>
// </template>
// `,
// config: {
// tailwind: {
// baseColor: 'stone',
// cssVariables: false,
// prefix: 'tw-',
// },
// aliases: {
// components: '@/components',
// utils: '@/lib/utils',
// },
// },
// baseColor: stone,
// }),
// ).toMatchSnapshot()
// expect(
// await transform({
// filename: 'app.vue',
// raw: `<template>
// <div id="testing" v-bind="props" @click="handleSomething" :data-test="true" :class="cn('bg-background hover:bg-muted', true && 'text-primary-foreground sm:focus:text-accent-foreground')">
// foo
// </div>
// </template>
// `,
// config: {
// tailwind: {
// baseColor: 'stone',
// cssVariables: false,
// prefix: 'tw-',
// },
// aliases: {
// components: '@/components',
// utils: '@/lib/utils',
// },
// },
// baseColor: stone,
// }),
// ).toMatchSnapshot()
// expect(
// applyPrefixesCss(
// '@tailwind base;\n@tailwind components;\n@tailwind utilities;\n \n@layer base {\n :root {\n --background: 0 0% 100%;\n --foreground: 224 71.4% 4.1%;\n \n --muted: 220 14.3% 95.9%;\n --muted-foreground: 220 8.9% 46.1%;\n \n --popover: 0 0% 100%;\n --popover-foreground: 224 71.4% 4.1%;\n \n --card: 0 0% 100%;\n --card-foreground: 224 71.4% 4.1%;\n \n --border: 220 13% 91%;\n --input: 220 13% 91%;\n \n --primary: 220.9 39.3% 11%;\n --primary-foreground: 210 20% 98%;\n \n --secondary: 220 14.3% 95.9%;\n --secondary-foreground: 220.9 39.3% 11%;\n \n --accent: 220 14.3% 95.9%;\n --accent-foreground: 220.9 39.3% 11%;\n \n --destructive: 0 84.2% 60.2%;\n --destructive-foreground: 210 20% 98%;\n \n --ring: 217.9 10.6% 64.9%;\n \n --radius: 0.5rem;\n }\n \n .dark {\n --background: 224 71.4% 4.1%;\n --foreground: 210 20% 98%;\n \n --muted: 215 27.9% 16.9%;\n --muted-foreground: 217.9 10.6% 64.9%;\n \n --popover: 224 71.4% 4.1%;\n --popover-foreground: 210 20% 98%;\n \n --card: 224 71.4% 4.1%;\n --card-foreground: 210 20% 98%;\n \n --border: 215 27.9% 16.9%;\n --input: 215 27.9% 16.9%;\n \n --primary: 210 20% 98%;\n --primary-foreground: 220.9 39.3% 11%;\n \n --secondary: 215 27.9% 16.9%;\n --secondary-foreground: 210 20% 98%;\n \n --accent: 215 27.9% 16.9%;\n --accent-foreground: 210 20% 98%;\n \n --destructive: 0 62.8% 30.6%;\n --destructive-foreground: 0 85.7% 97.3%;\n \n --ring: 215 27.9% 16.9%;\n }\n}\n \n@layer base {\n * {\n @apply border-border;\n }\n body {\n @apply bg-background text-foreground;\n }\n}',
// 'tw-',
// ),
// ).toMatchSnapshot()
})

File diff suppressed because it is too large Load Diff