Files
shadcn-ui/apps/v4/scripts/build-registry.mts
2026-01-31 11:34:35 +04:00

635 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
}