feat(cli): tree resolver

This commit is contained in:
shadcn
2024-08-19 16:37:35 +04:00
parent 2744218d71
commit f15a22073f
293 changed files with 6772 additions and 633 deletions

View File

@@ -68,6 +68,11 @@ export const add = new Command()
const registryIndex = await getRegistryIndex()
if (!registryIndex) {
handleError(new Error("Failed to fetch registry index."))
process.exit(1)
}
let selectedComponents = options.all
? registryIndex.map((entry) => entry.name)
: options.components
@@ -98,7 +103,7 @@ export const add = new Command()
const payload = await fetchTree(config.style, tree)
const baseColor = await getRegistryBaseColor(config.tailwind.baseColor)
if (!payload.length) {
if (!payload?.length) {
logger.warn("Selected components not found. Exiting.")
process.exit(0)
}
@@ -133,11 +138,11 @@ export const add = new Command()
await fs.mkdir(targetDir, { recursive: true })
}
const existingComponent = item.files.filter((file) =>
existsSync(path.resolve(targetDir, file.name))
const existingComponent = item.files?.filter((file) =>
existsSync(path.resolve(targetDir, file.path))
)
if (existingComponent.length && !options.overwrite) {
if (existingComponent?.length && !options.overwrite) {
if (selectedComponents.includes(item.name)) {
spinner.stop()
const { overwrite } = await prompts({
@@ -162,12 +167,16 @@ export const add = new Command()
}
}
for (const file of item.files) {
let filePath = path.resolve(targetDir, file.name)
for (const file of item.files ?? []) {
let filePath = path.resolve(targetDir, file.path)
// Run transformers.
if (!file.content) {
continue
}
const content = await transform({
filename: file.name,
filename: file.path,
raw: file.content,
config,
baseColor,

View File

@@ -59,13 +59,18 @@ export const diff = new Command()
const registryIndex = await getRegistryIndex()
if (!registryIndex) {
handleError(new Error("Failed to fetch registry index."))
process.exit(1)
}
if (!options.component) {
const targetDir = config.resolvedPaths.components
// Find all components that exist in the project.
const projectComponents = registryIndex.filter((item) => {
for (const file of item.files) {
const filePath = path.resolve(targetDir, file)
for (const file of item.files ?? []) {
const filePath = path.resolve(targetDir, file.path)
if (existsSync(filePath)) {
return true
}
@@ -139,6 +144,10 @@ async function diffComponent(
const payload = await fetchTree(config.style, [component])
const baseColor = await getRegistryBaseColor(config.tailwind.baseColor)
if (!payload) {
return []
}
const changes = []
for (const item of payload) {
@@ -148,8 +157,8 @@ async function diffComponent(
continue
}
for (const file of item.files) {
const filePath = path.resolve(targetDir, file.name)
for (const file of item.files ?? []) {
const filePath = path.resolve(targetDir, file.path)
if (!existsSync(filePath)) {
continue
@@ -157,8 +166,12 @@ async function diffComponent(
const fileContent = await fs.readFile(filePath, "utf8")
if (!file.content) {
continue
}
const registryContent = await transform({
filename: file.name,
filename: file.path,
raw: file.content,
config,
baseColor,
@@ -167,7 +180,6 @@ async function diffComponent(
const patch = diffLines(registryContent as string, fileContent)
if (patch.length > 1) {
changes.push({
file: file.name,
filePath,
patch,
})

View File

@@ -19,10 +19,11 @@ import {
getRegistryBaseColors,
getRegistryItem,
getRegistryStyles,
registryResolveItemsTree,
} from "@/src/utils/registry"
import { updateDependencies } from "@/src/utils/updaters/update-dependencies"
import { updateDestinations } from "@/src/utils/updaters/update-destinations"
import { updateFiles } from "@/src/utils/updaters/update-files"
import { updateRegistryDependencies } from "@/src/utils/updaters/update-registry-dependencies"
import {
buildTailwindThemeColorsFromCssVars,
updateTailwindConfig,
@@ -97,10 +98,10 @@ export const init = new Command()
if (!opts.force && !opts.defaults) {
logger.info("")
}
const spinner = ora(`Writing components.json...`).start()
const spinner = ora(`Writing components.json.`).start()
const targetPath = path.resolve(cwd, "components.json")
await fs.writeFile(targetPath, JSON.stringify(config, null, 2), "utf8")
spinner.succeed(`Writing components.json.`)
spinner.succeed()
const fullConfig = await resolveConfigPaths(cwd, config)
@@ -291,60 +292,25 @@ export async function promptForMinimalConfig(
}
export async function runInit(config: Config) {
const initializersSpinner = ora(`Initializing project...`)?.start()
await updateDestinations(config)
const [payload, baseColor] = await Promise.all([
getRegistryItem(config.style, "index"),
getRegistryBaseColor(config.tailwind.baseColor),
])
if (!payload) {
const initializersSpinner = ora(`Initializing project.`)?.start()
const tree = await registryResolveItemsTree(["index"], config)
if (!tree) {
initializersSpinner?.fail()
logger.error(`Something went wrong during the initialization process.`)
process.exit(1)
}
// Inline the base color in the tailwind config.
if (config.tailwind.cssVariables && baseColor) {
payload.cssVars = {
light: {
...baseColor.cssVars.light,
...payload.cssVars?.light,
},
dark: {
...baseColor.cssVars.dark,
...payload.cssVars?.dark,
},
}
console.log(tree.tailwind?.config?.theme)
// Move the css vars to the tailwind config.
if (payload.tailwind?.config && baseColor.cssVars?.light) {
payload.tailwind.config = deepmerge(payload.tailwind.config, {
theme: {
extend: {
colors: buildTailwindThemeColorsFromCssVars(
baseColor.cssVars.light
),
},
},
})
}
}
await updateTailwindConfig(tree.tailwind?.config, config)
await updateTailwindCss(tree.cssVars, config)
initializersSpinner?.succeed()
if (payload.tailwind?.config) {
await updateTailwindConfig(payload.tailwind?.config, config)
}
if (payload.cssVars) {
await updateTailwindCss(payload.cssVars, config)
}
await updateFiles(payload.files, config)
initializersSpinner?.succeed(`Initializing project.`)
// Install dependencies.
if (payload.dependencies) {
const dependenciesSpinner = ora(`Installing dependencies...`)?.start()
await updateDependencies(payload.dependencies, config)
dependenciesSpinner?.succeed(`Installing dependencies.`)
}
const dependenciesSpinner = ora(`Installing dependencies.`)?.start()
// await updateRegistryDependencies(tree.registryDependencies, config)
await updateDependencies(tree.dependencies, config)
dependenciesSpinner?.succeed()
}

View File

@@ -15,6 +15,7 @@ export function handleError(error: unknown) {
}
if (error instanceof z.ZodError) {
console.log(error.issues)
logger.error("Validation failed:")
for (const [key, value] of Object.entries(error.flatten().fieldErrors)) {
logger.error(`- ${cyan(key)}: ${value}`)

View File

@@ -5,11 +5,14 @@ import { logger } from "@/src/utils/logger"
import {
registryBaseColorSchema,
registryIndexSchema,
registryItemFileSchema,
registryItemSchema,
registryItemWithContentSchema,
registryWithContentSchema,
registryItemTypeSchema,
registryResolvedItemsTreeSchema,
stylesSchema,
} from "@/src/utils/registry/schema"
import { buildTailwindThemeColorsFromCssVars } from "@/src/utils/updaters/update-tailwind-config"
import deepmerge from "deepmerge"
import { HttpsProxyAgent } from "https-proxy-agent"
import { cyan } from "kleur/colors"
import fetch from "node-fetch"
@@ -28,7 +31,8 @@ export async function getRegistryIndex() {
return registryIndexSchema.parse(result)
} catch (error) {
throw new Error(`Failed to fetch components from registry.`)
logger.error("\n")
handleError(error)
}
}
@@ -38,7 +42,9 @@ export async function getRegistryStyles() {
return stylesSchema.parse(result)
} catch (error) {
throw new Error(`Failed to fetch styles from registry.`)
logger.error("\n")
handleError(error)
return []
}
}
@@ -48,6 +54,7 @@ export async function getRegistryItem(style: string, name: string) {
return registryItemSchema.parse(result)
} catch (error) {
logger.error("\n")
handleError(error)
return null
@@ -123,16 +130,15 @@ export async function fetchTree(
try {
const paths = tree.map((item) => `styles/${style}/${item.name}.json`)
const result = await fetchRegistry(paths)
return registryWithContentSchema.parse(result)
return registryIndexSchema.parse(result)
} catch (error) {
throw new Error(`Failed to fetch tree from registry.`)
handleError(error)
}
}
export async function getItemTargetPath(
config: Config,
item: Pick<z.infer<typeof registryItemWithContentSchema>, "type">,
item: z.infer<typeof registryItemSchema>,
override?: string
) {
if (override) {
@@ -183,3 +189,130 @@ async function fetchRegistry(paths: string[]) {
return []
}
}
export async function getRegistryItemFileTargetPath(
file: z.infer<typeof registryItemFileSchema>,
config: Config,
override?: string
) {
if (override) {
return override
}
if (file.type === "registry:ui") {
return config.resolvedPaths.ui
}
if (file.type === "registry:lib") {
return config.resolvedPaths.utils
}
if (file.type === "registry:component") {
return config.resolvedPaths.components
}
return null
}
export async function registryResolveItemsTree(
names: z.infer<typeof registryItemSchema>["name"][],
config: Config
) {
const index = await getRegistryIndex()
if (!index) {
return null
}
let items = (
await Promise.all(
names.map(async (name) => {
const item = await getRegistryItem(config.style, name)
return item
})
)
).filter((item): item is NonNullable<typeof item> => item !== null)
if (!items.length) {
return null
}
const registryDependencies: string[] = items
.map((item) => item.registryDependencies ?? [])
.flat()
const uniqueDependencies = Array.from(new Set(registryDependencies))
const tree = await resolveTree(index, [...names, ...uniqueDependencies])
let payload = await fetchTree(config.style, tree)
if (!payload) {
return null
}
if (names.includes("index")) {
const index = await getInitRegistryItem(config)
if (index) {
payload.unshift(index)
}
}
let tailwind = {}
payload.forEach((item) => {
console.log(item.tailwind?.config?.theme)
tailwind = deepmerge(tailwind, item.tailwind ?? {})
})
let cssVars = {}
payload.forEach((item) => {
cssVars = deepmerge(cssVars, item.cssVars ?? {})
})
return registryResolvedItemsTreeSchema.parse({
dependencies: deepmerge.all(payload.map((item) => item.dependencies ?? [])),
devDependencies: deepmerge.all(
payload.map((item) => item.devDependencies ?? [])
),
files: deepmerge.all(payload.map((item) => item.files ?? [])),
tailwind,
cssVars,
})
}
async function getInitRegistryItem(config: Config) {
const [payload, baseColor] = await Promise.all([
getRegistryItem(config.style, "index"),
getRegistryBaseColor(config.tailwind.baseColor),
])
if (!payload || !baseColor) {
return null
}
// Inline the base color in the tailwind config.
// TODO: Remove this when we have theme handling.
if (config.tailwind.cssVariables && baseColor) {
payload.cssVars = {
light: {
...baseColor.cssVars.light,
...payload.cssVars?.light,
},
dark: {
...baseColor.cssVars.dark,
...payload.cssVars?.dark,
},
}
if (payload.tailwind?.config && baseColor.cssVars?.light) {
payload.tailwind.config = deepmerge(payload.tailwind.config, {
theme: {
extend: {
colors: buildTailwindThemeColorsFromCssVars(
baseColor.cssVars.light
),
},
},
})
}
}
return payload
}

View File

@@ -1,34 +1,59 @@
import { z } from "zod"
// TODO: Extract this to a shared package.
export const legacyRegistryItemSchema = 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"])
export const registryItemTypeSchema = z.enum([
"components:ui",
"components:component",
"components:example",
"components:block",
"components:chart",
"registry:style",
"registry:lib",
"registry:component",
"registry:ui",
"registry:hook",
])
export const registryItemFileSchema = z.object({
path: z.string(),
content: z.string().optional(),
type: registryItemTypeSchema,
})
export const registryItemTailwindSchema = z.object({
config: z
.object({
content: z.array(z.string()).optional(),
theme: z.record(z.string(), z.any()).optional(),
plugins: z.array(z.string()).optional(),
})
.optional(),
})
export const registryIndexSchema = z.array(legacyRegistryItemSchema)
export const registryItemWithContentSchema = legacyRegistryItemSchema.extend({
files: z.array(
z.object({
name: z.string(),
content: z.string(),
type: z.enum([
"components:ui",
"components:component",
"components:example",
]),
})
),
export const registryItemCssVarsSchema = z.object({
light: z.record(z.string(), z.string()).optional(),
dark: z.record(z.string(), z.string()).optional(),
})
export const registryWithContentSchema = z.array(registryItemWithContentSchema)
export const registryItemSchema = z.object({
name: z.string(),
type: registryItemTypeSchema,
description: z.string().optional(),
dependencies: z.array(z.string()).optional(),
devDependencies: z.array(z.string()).optional(),
registryDependencies: z.array(z.string()).optional(),
files: z.array(registryItemFileSchema).optional(),
tailwind: registryItemTailwindSchema.optional(),
cssVars: registryItemCssVarsSchema.optional(),
})
export type RegistryItem = z.infer<typeof registryItemSchema>
export const registryIndexSchema = z.array(
registryItemSchema.extend({
files: z.array(z.union([z.string(), registryItemFileSchema])).optional(),
})
)
export const stylesSchema = z.array(
z.object({
@@ -50,33 +75,10 @@ export const registryBaseColorSchema = z.object({
cssVarsTemplate: z.string(),
})
export const registryCssVarsSchema = z.object({
light: z.record(z.string(), z.string()).optional(),
dark: z.record(z.string(), z.string()).optional(),
export const registryResolvedItemsTreeSchema = registryItemSchema.pick({
dependencies: true,
devDependencies: true,
files: true,
tailwind: true,
cssVars: true,
})
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.object({
name: z.string(),
content: z.string(),
type: z.enum(["utils", "ui", "component"]),
})
),
tailwind: z
.object({
config: z.object({
content: z.array(z.string()).optional(),
theme: z.record(z.string(), z.any()).optional(),
plugins: z.array(z.string()).optional(),
}),
})
.optional(),
cssVars: registryCssVarsSchema.optional(),
})
export type RegistryItem = z.infer<typeof registryItemSchema>

View File

@@ -1,11 +1,16 @@
import { Config } from "@/src/utils/get-config"
import { getPackageManager } from "@/src/utils/get-package-manager"
import { RegistryItem } from "@/src/utils/registry/schema"
import { execa } from "execa"
export async function updateDependencies(
dependencies: string[],
dependencies: RegistryItem["dependencies"],
config: Config
) {
if (!dependencies) {
return
}
const packageManager = await getPackageManager(config.resolvedPaths.cwd)
await execa(

View File

@@ -1,16 +0,0 @@
import { promises as fs } from "fs"
import { Config } from "@/src/utils/get-config"
import { RegistryItem } from "@/src/utils/registry/schema"
import * as templates from "@/src/utils/templates"
export async function updateFiles(
files: RegistryItem["files"],
config: Config
) {
const extension = config.tsx ? "ts" : "js"
await fs.writeFile(
`${config.resolvedPaths.utils}.${extension}`,
extension === "ts" ? templates.UTILS : templates.UTILS_JS,
"utf8"
)
}

View File

@@ -0,0 +1,38 @@
import { Config } from "@/src/utils/get-config"
import { fetchTree, getRegistryIndex, resolveTree } from "@/src/utils/registry"
import { RegistryItem } from "@/src/utils/registry/schema"
import { z } from "zod"
export async function updateRegistryDependencies(
registryDependencies: RegistryItem["registryDependencies"],
config: Config
) {
if (!registryDependencies?.length) {
return
}
const registryIndex = await getRegistryIndex()
if (!registryIndex) {
return null
}
const tree = await resolveTree(registryIndex, registryDependencies)
const payload = await fetchTree(config.style, tree)
if (!payload?.length) {
return
}
await Promise.all(
payload.map(async (dependency) => {
console.log(dependency)
// const targetPath = await getRegistryItemFileTargetPath(file, config)
// if (!targetPath) {
// return
// }
// console.log(targetPath)
})
)
}

View File

@@ -2,6 +2,10 @@ import { promises as fs } from "fs"
import { tmpdir } from "os"
import path from "path"
import { Config } from "@/src/utils/get-config"
import {
RegistryItem,
registryItemTailwindSchema,
} from "@/src/utils/registry/schema"
import deepmerge from "deepmerge"
import objectToString from "stringify-object"
import { type Config as TailwindConfig } from "tailwindcss"
@@ -14,6 +18,7 @@ import {
SyntaxKind,
VariableStatement,
} from "ts-morph"
import { z } from "zod"
export type UpdaterTailwindConfig = Omit<TailwindConfig, "plugins"> & {
// We only want string plugins for now.
@@ -21,9 +26,14 @@ export type UpdaterTailwindConfig = Omit<TailwindConfig, "plugins"> & {
}
export async function updateTailwindConfig(
tailwindConfig: UpdaterTailwindConfig,
tailwindConfig:
| z.infer<typeof registryItemTailwindSchema>["config"]
| undefined,
config: Config
) {
if (!tailwindConfig) {
return
}
const raw = await fs.readFile(config.resolvedPaths.tailwindConfig, "utf8")
const output = await transformTailwindConfig(raw, tailwindConfig, config)
await fs.writeFile(config.resolvedPaths.tailwindConfig, output, "utf8")

View File

@@ -1,6 +1,6 @@
import { promises as fs } from "fs"
import { Config } from "@/src/utils/get-config"
import { registryCssVarsSchema } from "@/src/utils/registry/schema"
import { registryItemCssVarsSchema } from "@/src/utils/registry/schema"
import postcss from "postcss"
import AtRule from "postcss/lib/at-rule"
import Root from "postcss/lib/root"
@@ -8,9 +8,13 @@ import Rule from "postcss/lib/rule"
import { z } from "zod"
export async function updateTailwindCss(
cssVars: z.infer<typeof registryCssVarsSchema>,
cssVars: z.infer<typeof registryItemCssVarsSchema> | undefined,
config: Config
) {
if (!cssVars) {
return
}
const raw = await fs.readFile(config.resolvedPaths.tailwindCss, "utf8")
let output = await transformTailwindCss(raw, cssVars)
@@ -19,7 +23,7 @@ export async function updateTailwindCss(
export async function transformTailwindCss(
input: string,
cssVars: z.infer<typeof registryCssVarsSchema>
cssVars: z.infer<typeof registryItemCssVarsSchema>
) {
const result = await postcss([
updateCssVarsPlugin(cssVars),
@@ -94,7 +98,9 @@ function updateBaseLayerPlugin() {
}
}
function updateCssVarsPlugin(cssVars: z.infer<typeof registryCssVarsSchema>) {
function updateCssVarsPlugin(
cssVars: z.infer<typeof registryItemCssVarsSchema>
) {
return {
postcssPlugin: "update-css-vars",
Once(root: Root) {
@@ -140,7 +146,7 @@ function addOrUpdateVars(
}
Object.entries(vars).forEach(([key, value]) => {
const prop = `--${key}`
const prop = `--${key.replace(/^--/, "")}`
const newDecl = postcss.decl({
prop,
value,