mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-27 06:34:12 +00:00
635 lines
19 KiB
TypeScript
635 lines
19 KiB
TypeScript
import { spawn } from "child_process"
|
||
import { promises as fs } from "fs"
|
||
import path from "path"
|
||
import { rimraf } from "rimraf"
|
||
import { registrySchema } from "shadcn/schema"
|
||
import {
|
||
createStyleMap,
|
||
transformDirection,
|
||
transformStyle,
|
||
} from "shadcn/utils"
|
||
|
||
import { getAllBlocks } from "@/lib/blocks"
|
||
import { legacyStyles } from "@/registry/_legacy-styles"
|
||
import { BASES, type Base } from "@/registry/bases"
|
||
import { PRESETS } from "@/registry/config"
|
||
import { STYLES } from "@/registry/styles"
|
||
|
||
// This is a list of styles that we want to check into tracking.
|
||
// This is used by the v4 site.
|
||
const WHITELISTED_STYLES = ["new-york-v4"]
|
||
|
||
// Collect paths for batch prettier formatting at the end.
|
||
const prettierPaths: string[] = []
|
||
|
||
function getStylesToBuild() {
|
||
const stylesToBuild: { name: string; title: string }[] = [...legacyStyles]
|
||
|
||
for (const base of BASES) {
|
||
for (const style of STYLES) {
|
||
stylesToBuild.push({
|
||
name: `${base.name}-${style.name}`,
|
||
title: `${base.title} ${style.title}`,
|
||
})
|
||
}
|
||
}
|
||
|
||
return stylesToBuild
|
||
}
|
||
|
||
try {
|
||
const totalStart = performance.now()
|
||
|
||
console.log("🏗️ Building bases...")
|
||
await buildBasesIndex(Array.from(BASES))
|
||
await buildBases(Array.from(BASES))
|
||
|
||
const stylesToBuild = getStylesToBuild()
|
||
|
||
// Build index for legacy styles and whitelisted base-style combinations.
|
||
console.log(`📦 Building registry/__index__.tsx...`)
|
||
const stylesForIndex = WHITELISTED_STYLES.map((name) => ({
|
||
name,
|
||
title: name,
|
||
}))
|
||
await buildRegistryIndex(stylesForIndex)
|
||
|
||
console.log("💅 Building styles...")
|
||
// Build all styles in parallel.
|
||
await Promise.all(
|
||
stylesToBuild.map(async (style) => {
|
||
await buildRegistryJsonFile(style.name)
|
||
await buildRegistry(style.name)
|
||
console.log(` ✅ ${style.name}`)
|
||
})
|
||
)
|
||
|
||
console.log("\n🗂️ Building registry/__blocks__.json...")
|
||
await buildBlocksIndex()
|
||
|
||
console.log("\n⚙️ Building public/r/config.json...")
|
||
await buildConfig()
|
||
|
||
// Copy UI to examples before cleanup.
|
||
console.log("\n📋 Copying UI to examples...")
|
||
await copyUIToExamples()
|
||
|
||
// Build RTL variants of examples.
|
||
console.log("\n🔄 Building RTL examples...")
|
||
await buildRtlExamples()
|
||
|
||
console.log("\n📋 Building public/r/registries.json...")
|
||
await buildRegistriesJson()
|
||
|
||
// Batch format all collected files with prettier at the end.
|
||
if (prettierPaths.length > 0) {
|
||
console.log(`\n✨ Formatting ${prettierPaths.length} files...`)
|
||
await batchPrettier(prettierPaths)
|
||
}
|
||
|
||
// Clean up intermediate files and generated base directories.
|
||
console.log("\n🧹 Cleaning up...")
|
||
await cleanUp(stylesToBuild)
|
||
|
||
const elapsed = ((performance.now() - totalStart) / 1000).toFixed(2)
|
||
console.log(`\n✅ Build complete in ${elapsed}s!`)
|
||
} catch (error) {
|
||
console.error(error)
|
||
process.exit(1)
|
||
}
|
||
|
||
async function buildBasesIndex(bases: Base[]) {
|
||
// Import all registries in parallel.
|
||
const registryImports = await Promise.all(
|
||
bases.map(async (base) => {
|
||
const { registry: importedRegistry } = await import(
|
||
`../registry/bases/${base.name}/registry.ts`
|
||
)
|
||
return { base, importedRegistry }
|
||
})
|
||
)
|
||
|
||
let index = `// @ts-nocheck
|
||
// This file is autogenerated by scripts/build-registry.ts
|
||
// Do not edit this file directly.
|
||
import "server-only"
|
||
import * as React from "react"
|
||
|
||
export const Index: Record<string, Record<string, any>> = {`
|
||
|
||
for (const { base, importedRegistry } of registryImports) {
|
||
// Validate the registry schema.
|
||
const parseResult = registrySchema.safeParse(importedRegistry)
|
||
if (!parseResult.success) {
|
||
console.error(`❌ Registry validation failed for ${base.name}:`)
|
||
console.error(parseResult.error.format())
|
||
throw new Error(`Invalid registry schema for ${base.name}`)
|
||
}
|
||
|
||
const registry = parseResult.data
|
||
|
||
index += `
|
||
"${base.name}": {`
|
||
|
||
for (const item of registry.items) {
|
||
// Skip demos - they're handled by the examples index.
|
||
if (item.type === "registry:internal") {
|
||
continue
|
||
}
|
||
|
||
const files =
|
||
item.files?.map((file) => ({
|
||
path: typeof file === "string" ? file : file.path,
|
||
type: typeof file === "string" ? item.type : file.type,
|
||
target: typeof file === "string" ? undefined : file.target,
|
||
})) ?? []
|
||
|
||
if (files.length === 0) {
|
||
continue
|
||
}
|
||
|
||
const componentPath = item.files?.[0]?.path
|
||
? `@/registry/bases/${base.name}/${item.files[0].path}`
|
||
: ""
|
||
|
||
index += `
|
||
"${item.name}": {
|
||
name: "${item.name}",
|
||
title: "${item.title}",
|
||
description: "${item.description ?? ""}",
|
||
type: "${item.type}",
|
||
registryDependencies: ${JSON.stringify(item.registryDependencies)},
|
||
files: [${files.map((file) => {
|
||
const filePath = `registry/bases/${base.name}/${file.path}`
|
||
return `{
|
||
path: "${filePath}",
|
||
type: "${file.type}",
|
||
target: "${file.target ?? ""}"
|
||
}`
|
||
})}],
|
||
component: ${
|
||
componentPath
|
||
? `React.lazy(async () => {
|
||
const mod = await import("${componentPath}")
|
||
const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name
|
||
return { default: mod.default || mod[exportName] }
|
||
})`
|
||
: "null"
|
||
},
|
||
categories: ${JSON.stringify(item.categories)},
|
||
meta: ${JSON.stringify(item.meta)},
|
||
},`
|
||
}
|
||
|
||
index += `
|
||
},`
|
||
}
|
||
|
||
index += `
|
||
}`
|
||
|
||
// Write unified index.
|
||
await rimraf(path.join(process.cwd(), "registry/bases/__index__.tsx"))
|
||
await fs.writeFile(
|
||
path.join(process.cwd(), "registry/bases/__index__.tsx"),
|
||
index
|
||
)
|
||
}
|
||
|
||
async function buildBases(bases: Base[]) {
|
||
// Pre-import all base registries and style CSS files in parallel.
|
||
const [baseImports, styleMaps] = await Promise.all([
|
||
Promise.all(
|
||
bases.map(async (base) => {
|
||
const { registry: baseRegistry } = await import(
|
||
`../registry/bases/${base.name}/registry.ts`
|
||
)
|
||
const result = registrySchema.safeParse(baseRegistry)
|
||
if (!result.success) {
|
||
console.error(`❌ Registry validation failed for ${base.name}:`)
|
||
console.error(result.error.format())
|
||
throw new Error(`Invalid registry schema for ${base.name}`)
|
||
}
|
||
const registryItems = result.data.items.filter(
|
||
(item) => item.type !== "registry:internal"
|
||
)
|
||
return { base, baseRegistry, registryItems }
|
||
})
|
||
),
|
||
Promise.all(
|
||
STYLES.map(async (style) => {
|
||
const styleContent = await fs.readFile(
|
||
path.join(process.cwd(), `registry/styles/style-${style.name}.css`),
|
||
"utf8"
|
||
)
|
||
return { style, styleMap: createStyleMap(styleContent) }
|
||
})
|
||
),
|
||
])
|
||
|
||
// Build all base-style combinations in parallel.
|
||
const combinations: Array<{
|
||
base: Base
|
||
style: (typeof STYLES)[number]
|
||
baseRegistry: (typeof baseImports)[number]["baseRegistry"]
|
||
registryItems: (typeof baseImports)[number]["registryItems"]
|
||
styleMap: Record<string, string>
|
||
}> = []
|
||
|
||
for (const { base, baseRegistry, registryItems } of baseImports) {
|
||
for (const { style, styleMap } of styleMaps) {
|
||
combinations.push({ base, style, baseRegistry, registryItems, styleMap })
|
||
}
|
||
}
|
||
|
||
await Promise.all(
|
||
combinations.map(
|
||
async ({ base, style, baseRegistry, registryItems, styleMap }) => {
|
||
console.log(` ✅ ${base.name}-${style.name}...`)
|
||
|
||
const styleOutputDir = path.join(
|
||
process.cwd(),
|
||
`registry/${base.name}-${style.name}`
|
||
)
|
||
await rimraf(styleOutputDir)
|
||
await fs.mkdir(styleOutputDir, { recursive: true })
|
||
|
||
// Create a registry.ts file in the output directory.
|
||
const styleRegistry = { ...baseRegistry, items: registryItems }
|
||
const registryTs = `export const registry = ${JSON.stringify(styleRegistry, null, 2)}\n`
|
||
await fs.writeFile(path.join(styleOutputDir, "registry.ts"), registryTs)
|
||
|
||
// Process all files in parallel.
|
||
await Promise.all(
|
||
registryItems.flatMap((registryItem) => {
|
||
if (!registryItem.files || registryItem.files.length === 0) {
|
||
return []
|
||
}
|
||
return registryItem.files.map(async (file: { path: string }) => {
|
||
const source = await fs.readFile(
|
||
path.join(
|
||
process.cwd(),
|
||
`registry/bases/${base.name}/${file.path}`
|
||
),
|
||
"utf8"
|
||
)
|
||
|
||
const fileExtension = path.extname(file.path)
|
||
const shouldTransform =
|
||
fileExtension === ".tsx" || fileExtension === ".ts"
|
||
|
||
let transformedContent = source
|
||
|
||
if (shouldTransform) {
|
||
transformedContent = await transformStyle(source, {
|
||
styleMap: styleMap,
|
||
})
|
||
transformedContent = transformedContent.replace(
|
||
new RegExp(`@/registry/bases/${base.name}/`, "g"),
|
||
`@/registry/${base.name}-${style.name}/`
|
||
)
|
||
}
|
||
|
||
const outputPath = path.join(
|
||
process.cwd(),
|
||
`registry/${base.name}-${style.name}/${file.path}`
|
||
)
|
||
await fs.mkdir(path.dirname(outputPath), { recursive: true })
|
||
await fs.writeFile(outputPath, transformedContent)
|
||
})
|
||
})
|
||
)
|
||
}
|
||
)
|
||
)
|
||
}
|
||
|
||
async function buildRegistryIndex(styles: { name: string; title: string }[]) {
|
||
// Import all registries in parallel.
|
||
const registryImports = await Promise.all(
|
||
styles.map(async (style) => {
|
||
const { registry: importedRegistry } = await import(
|
||
`../registry/${style.name}/registry.ts`
|
||
)
|
||
return { style, importedRegistry }
|
||
})
|
||
)
|
||
|
||
let index = `// @ts-nocheck
|
||
// This file is autogenerated by scripts/build-registry.ts
|
||
// Do not edit this file directly.
|
||
import * as React from "react"
|
||
|
||
export const Index: Record<string, Record<string, any>> = {`
|
||
|
||
for (const { style, importedRegistry } of registryImports) {
|
||
const parseResult = registrySchema.safeParse(importedRegistry)
|
||
if (!parseResult.success) {
|
||
console.error(`❌ Registry validation failed for ${style.name}:`)
|
||
console.error(parseResult.error.format())
|
||
throw new Error(`Invalid registry schema for ${style.name}`)
|
||
}
|
||
|
||
const registry = parseResult.data
|
||
|
||
index += `
|
||
"${style.name}": {`
|
||
|
||
for (const item of registry.items) {
|
||
if (item.type === "registry:internal") {
|
||
continue
|
||
}
|
||
|
||
const files =
|
||
item.files?.map((file) => ({
|
||
path: typeof file === "string" ? file : file.path,
|
||
type: typeof file === "string" ? item.type : file.type,
|
||
target: typeof file === "string" ? undefined : file.target,
|
||
})) ?? []
|
||
|
||
if (files.length === 0) {
|
||
continue
|
||
}
|
||
|
||
const componentPath = item.files?.[0]?.path
|
||
? `@/registry/${style.name}/${item.files[0].path}`
|
||
: ""
|
||
|
||
index += `
|
||
"${item.name}": {
|
||
name: "${item.name}",
|
||
title: "${item.title}",
|
||
description: "${item.description ?? ""}",
|
||
type: "${item.type}",
|
||
registryDependencies: ${JSON.stringify(item.registryDependencies)},
|
||
files: [${files.map((file) => {
|
||
const filePath = `registry/${style.name}/${file.path}`
|
||
return `{
|
||
path: "${filePath}",
|
||
type: "${file.type}",
|
||
target: "${file.target ?? ""}"
|
||
}`
|
||
})}],
|
||
component: ${
|
||
componentPath
|
||
? `React.lazy(async () => {
|
||
const mod = await import("${componentPath}")
|
||
const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || item.name
|
||
return { default: mod.default || mod[exportName] }
|
||
})`
|
||
: "null"
|
||
},
|
||
categories: ${JSON.stringify(item.categories)},
|
||
meta: ${JSON.stringify(item.meta)},
|
||
},`
|
||
}
|
||
|
||
index += `
|
||
},`
|
||
}
|
||
|
||
index += `
|
||
}`
|
||
|
||
await rimraf(path.join(process.cwd(), "registry/__index__.tsx"))
|
||
await fs.writeFile(path.join(process.cwd(), "registry/__index__.tsx"), index)
|
||
}
|
||
|
||
async function buildRegistryJsonFile(styleName: string) {
|
||
const { registry: importedRegistry } = await import(
|
||
`../registry/${styleName}/registry.ts`
|
||
)
|
||
|
||
const parseResult = registrySchema.safeParse(importedRegistry)
|
||
if (!parseResult.success) {
|
||
console.error(`❌ Registry validation failed for ${styleName}:`)
|
||
console.error(parseResult.error.format())
|
||
throw new Error(`Invalid registry schema for ${styleName}`)
|
||
}
|
||
|
||
const registry = parseResult.data
|
||
|
||
const fixedRegistry = {
|
||
...registry,
|
||
items: registry.items.map((item) => {
|
||
const files = item.files?.map((file) => ({
|
||
...file,
|
||
path: `registry/${styleName}/${file.path}`,
|
||
}))
|
||
return { ...item, files }
|
||
}),
|
||
}
|
||
|
||
const outputDir = path.join(process.cwd(), `public/r/styles/${styleName}`)
|
||
await fs.mkdir(outputDir, { recursive: true })
|
||
|
||
const registryJsonPath = path.join(outputDir, "registry.json")
|
||
await fs.writeFile(registryJsonPath, JSON.stringify(fixedRegistry, null, 2))
|
||
prettierPaths.push(registryJsonPath)
|
||
|
||
const tempRegistryPath = path.join(
|
||
process.cwd(),
|
||
`registry-${styleName}.json`
|
||
)
|
||
await fs.writeFile(tempRegistryPath, JSON.stringify(fixedRegistry, null, 2))
|
||
}
|
||
|
||
async function buildRegistry(styleName: string) {
|
||
const outputPath = `public/r/styles/${styleName}`
|
||
await new Promise<void>((resolve, reject) => {
|
||
const proc = spawn(
|
||
"node",
|
||
[
|
||
"../../packages/shadcn/dist/index.js",
|
||
"build",
|
||
`registry-${styleName}.json`,
|
||
"--output",
|
||
outputPath,
|
||
],
|
||
{ cwd: process.cwd(), stdio: "pipe" }
|
||
)
|
||
let stderr = ""
|
||
proc.stderr?.on("data", (data) => (stderr += data))
|
||
proc.on("close", (code) => {
|
||
if (code !== 0) {
|
||
reject(new Error(`Process exited with code ${code}: ${stderr}`))
|
||
} else {
|
||
resolve()
|
||
}
|
||
})
|
||
proc.on("error", reject)
|
||
})
|
||
}
|
||
|
||
async function buildBlocksIndex() {
|
||
const blocks = await getAllBlocks(["registry:block"])
|
||
|
||
const payload = blocks.map((block) => ({
|
||
name: block.name,
|
||
description: block.description,
|
||
categories: block.categories,
|
||
}))
|
||
|
||
await rimraf(path.join(process.cwd(), "registry/__blocks__.json"))
|
||
const blocksJsonPath = path.join(process.cwd(), "registry/__blocks__.json")
|
||
await fs.writeFile(blocksJsonPath, JSON.stringify(payload, null, 2))
|
||
prettierPaths.push(blocksJsonPath)
|
||
}
|
||
|
||
async function cleanUp(stylesToBuild: { name: string; title: string }[]) {
|
||
// Clean up all in parallel.
|
||
const cleanupPromises: Promise<boolean>[] = []
|
||
|
||
for (const style of stylesToBuild) {
|
||
cleanupPromises.push(
|
||
rimraf(path.join(process.cwd(), `registry-${style.name}.json`))
|
||
)
|
||
}
|
||
|
||
for (const base of BASES) {
|
||
for (const style of STYLES) {
|
||
const baseName = `${base.name}-${style.name}`
|
||
if (!WHITELISTED_STYLES.includes(baseName)) {
|
||
const baseDir = path.join(process.cwd(), `registry/${baseName}`)
|
||
console.log(` 🗑️ ${baseName}`)
|
||
cleanupPromises.push(rimraf(baseDir))
|
||
}
|
||
}
|
||
}
|
||
|
||
await Promise.all(cleanupPromises)
|
||
}
|
||
|
||
async function buildConfig() {
|
||
const config = { presets: PRESETS }
|
||
const outputPath = path.join(process.cwd(), "public/r/config.json")
|
||
await fs.writeFile(outputPath, JSON.stringify(config, null, 2))
|
||
prettierPaths.push(outputPath)
|
||
}
|
||
|
||
async function copyUIToExamples() {
|
||
const defaultStyle = "nova"
|
||
const directories = ["ui", "lib", "hooks"]
|
||
|
||
// Build all copy tasks.
|
||
const copyTasks: Array<{ base: Base; dir: string }> = []
|
||
for (const base of BASES) {
|
||
for (const dir of directories) {
|
||
copyTasks.push({ base, dir })
|
||
}
|
||
}
|
||
|
||
await Promise.all(
|
||
copyTasks.map(async ({ base, dir }) => {
|
||
const sourceStyle = `${base.name}-${defaultStyle}`
|
||
const fromDir = path.join(process.cwd(), `registry/${sourceStyle}/${dir}`)
|
||
const toDir = path.join(process.cwd(), `examples/${base.name}/${dir}`)
|
||
|
||
try {
|
||
await fs.access(fromDir)
|
||
} catch {
|
||
console.log(` ⚠️ registry/${sourceStyle}/${dir} not found, skipping`)
|
||
return
|
||
}
|
||
|
||
await rimraf(toDir)
|
||
await fs.mkdir(toDir, { recursive: true })
|
||
|
||
const files = await fs.readdir(fromDir)
|
||
await Promise.all(
|
||
files.map(async (file) => {
|
||
const sourcePath = path.join(fromDir, file)
|
||
const targetPath = path.join(toDir, file)
|
||
let content = await fs.readFile(sourcePath, "utf-8")
|
||
content = content.replace(
|
||
new RegExp(`@/registry/${sourceStyle}/`, "g"),
|
||
`@/examples/${base.name}/`
|
||
)
|
||
await fs.writeFile(targetPath, content)
|
||
})
|
||
)
|
||
|
||
console.log(
|
||
` ✅ registry/${sourceStyle}/${dir} → examples/${base.name}/${dir}`
|
||
)
|
||
})
|
||
)
|
||
}
|
||
|
||
async function buildRegistriesJson() {
|
||
const directoryPath = path.join(process.cwd(), "registry/directory.json")
|
||
const directoryContent = await fs.readFile(directoryPath, "utf8")
|
||
const directory = JSON.parse(directoryContent) as Array<{
|
||
name: string
|
||
homepage?: string
|
||
url: string
|
||
description?: string
|
||
featured?: boolean
|
||
logo?: string
|
||
}>
|
||
|
||
const registries = directory.map((entry) => ({
|
||
name: entry.name,
|
||
homepage: entry.homepage,
|
||
url: entry.url,
|
||
description: entry.description,
|
||
}))
|
||
|
||
const outputPath = path.join(process.cwd(), "public/r/registries.json")
|
||
await fs.writeFile(outputPath, JSON.stringify(registries, null, 2))
|
||
prettierPaths.push(outputPath)
|
||
}
|
||
|
||
async function buildRtlExamples() {
|
||
// Process all bases in parallel.
|
||
await Promise.all(
|
||
Array.from(BASES).map(async (base) => {
|
||
const sourceDir = path.join(process.cwd(), `examples/${base.name}/ui`)
|
||
const targetDir = path.join(process.cwd(), `examples/${base.name}/ui-rtl`)
|
||
|
||
try {
|
||
await fs.access(sourceDir)
|
||
} catch {
|
||
console.log(` ⚠️ examples/${base.name}/ui not found, skipping...`)
|
||
return
|
||
}
|
||
|
||
await rimraf(targetDir)
|
||
await fs.mkdir(targetDir, { recursive: true })
|
||
|
||
const files = await fs.readdir(sourceDir)
|
||
await Promise.all(
|
||
files
|
||
.filter((file) => file.endsWith(".tsx") || file.endsWith(".ts"))
|
||
.map(async (file) => {
|
||
const sourcePath = path.join(sourceDir, file)
|
||
const targetPath = path.join(targetDir, file)
|
||
|
||
let content = await fs.readFile(sourcePath, "utf-8")
|
||
content = await transformDirection(content, true)
|
||
content = content.replace(
|
||
new RegExp(`@/examples/${base.name}/ui/`, "g"),
|
||
`@/examples/${base.name}/ui-rtl/`
|
||
)
|
||
await fs.writeFile(targetPath, content)
|
||
})
|
||
)
|
||
|
||
console.log(` ✅ examples/${base.name}/ui-rtl`)
|
||
})
|
||
)
|
||
}
|
||
|
||
async function batchPrettier(paths: string[]) {
|
||
if (paths.length === 0) return
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
const proc = spawn("npx", ["prettier", "--write", ...paths], {
|
||
cwd: process.cwd(),
|
||
stdio: "pipe",
|
||
})
|
||
proc.on("close", () => resolve())
|
||
proc.on("error", reject)
|
||
})
|
||
}
|