Files
shadcn-ui/apps/v4/scripts/build-registry.mts
shadcn dced7f6045 fix
2026-01-08 22:25:29 +04:00

529 lines
16 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 { exec, execFile } from "child_process"
import { promises as fs } from "fs"
import path from "path"
import { rimraf } from "rimraf"
import { registrySchema } from "shadcn/schema"
import { createStyleMap, 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"]
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 {
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...")
for (const style of stylesToBuild) {
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()
// Clean up intermediate files and generated base directories.
console.log("\n🧹 Cleaning up...")
await cleanUp(stylesToBuild)
console.log("\n✅ Build complete!")
} catch (error) {
console.error(error)
process.exit(1)
}
async function buildBasesIndex(bases: Base[]) {
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 of bases) {
// Dynamically import the registry for this style.
const { registry: importedRegistry } = await import(
`../registry/bases/${base.name}/registry.ts`
)
// 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.
rimraf.sync(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[]) {
for (const base of bases) {
const { registry: baseRegistry } = await import(
`../registry/bases/${base.name}/registry.ts`
)
// Validate the registry schema.
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}`)
}
// Filter out demos - they're handled by the examples index.
const registryItems = result.data.items.filter(
(item) => item.type !== "registry:internal"
)
for (const style of STYLES) {
console.log(`${base.name}-${style.name}...`)
// Create the base-style output directory if it doesn't exist.
const styleOutputDir = path.join(
process.cwd(),
`registry/${base.name}-${style.name}`
)
rimraf.sync(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)
const styleContent = await fs.readFile(
path.join(process.cwd(), `registry/styles/style-${style.name}.css`),
"utf8"
)
const styleMap = createStyleMap(styleContent)
for (const registryItem of registryItems) {
// Process all files in the registry item.
if (!registryItem.files || registryItem.files.length === 0) {
continue
}
for (const file of registryItem.files) {
const source = await fs.readFile(
path.join(
process.cwd(),
`registry/bases/${base.name}/${file.path}`
),
"utf8"
)
// Transform the file if it's a TSX/TS file that needs style transformation.
const fileExtension = path.extname(file.path)
const shouldTransform =
fileExtension === ".tsx" || fileExtension === ".ts"
let transformedContent = source
if (shouldTransform) {
// Transform style classes (cn-* -> Tailwind).
transformedContent = await transformStyle(source, {
styleMap: styleMap,
})
// Transform import paths from base to style-specific paths.
// e.g., @/registry/bases/radix/ui/button -> @/registry/radix-nova/ui/button
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}`
)
const outputDir = path.dirname(outputPath)
await fs.mkdir(outputDir, { recursive: true })
await fs.writeFile(outputPath, transformedContent)
}
}
}
}
}
async function buildRegistryIndex(styles: { name: string; title: string }[]) {
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 of styles) {
// Dynamically import the registry for this style.
const { registry: importedRegistry } = await import(
`../registry/${style.name}/registry.ts`
)
// Validate the registry schema.
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) {
// 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/${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 += `
}`
// Write unified index.
rimraf.sync(path.join(process.cwd(), "registry/__index__.tsx"))
await fs.writeFile(path.join(process.cwd(), "registry/__index__.tsx"), index)
}
async function buildRegistryJsonFile(styleName: string) {
// 1. Import the registry for this style.
const { registry: importedRegistry } = await import(
`../registry/${styleName}/registry.ts`
)
// 2. Validate the registry schema.
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
// 3. Fix the path for registry items.
const fixedRegistry = {
...registry,
items: registry.items.map((item) => {
const files = item.files?.map((file) => {
return {
...file,
path: `registry/${styleName}/${file.path}`,
}
})
return {
...item,
files,
}
}),
// We're only going to build registry:ui for the new bases for now.
// .filter((item) => ["registry:ui"].includes(item.type)),
}
// 3. Create the output directory and write registry.json.
const outputDir = path.join(process.cwd(), `public/r/styles/${styleName}`)
await fs.mkdir(outputDir, { recursive: true })
// 4. Write registry.json to output directory and format it.
const registryJsonPath = path.join(outputDir, "registry.json")
await fs.writeFile(registryJsonPath, JSON.stringify(fixedRegistry, null, 2))
await new Promise<void>((resolve, reject) => {
execFile("prettier", ["--write", registryJsonPath], (error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
// 5. Write temporary registry file needed by shadcn build.
const tempRegistryPath = path.join(
process.cwd(),
`registry-${styleName}.json`
)
await fs.writeFile(tempRegistryPath, JSON.stringify(fixedRegistry, null, 2))
}
async function buildRegistry(styleName: string) {
return new Promise((resolve, reject) => {
const outputPath = `public/r/styles/${styleName}`
const process = exec(
`node ../../packages/shadcn/dist/index.js build registry-${styleName}.json --output ${outputPath}`
)
process.on("exit", (code) => {
if (code === 0) {
resolve(undefined)
} else {
reject(new Error(`Process exited with code ${code}`))
}
})
})
}
async function buildBlocksIndex() {
const blocks = await getAllBlocks(["registry:block"])
const payload = blocks.map((block) => ({
name: block.name,
description: block.description,
categories: block.categories,
}))
rimraf.sync(path.join(process.cwd(), "registry/__blocks__.json"))
await fs.writeFile(
path.join(process.cwd(), "registry/__blocks__.json"),
JSON.stringify(payload, null, 2)
)
await exec(`prettier --write registry/__blocks__.json`)
}
async function cleanUp(stylesToBuild: { name: string; title: string }[]) {
// Clean up intermediate registry JSON files.
for (const style of stylesToBuild) {
rimraf.sync(path.join(process.cwd(), `registry-${style.name}.json`))
}
// Clean up generated base directories except whitelisted ones.
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}`)
rimraf.sync(baseDir)
}
}
}
}
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))
// Format with prettier.
await new Promise<void>((resolve, reject) => {
execFile("prettier", ["--write", outputPath], (error) => {
if (error) {
reject(error)
} else {
resolve()
}
})
})
}
async function copyUIToExamples() {
const defaultStyle = "nova"
const directories = ["ui", "lib", "hooks"]
for (const base of BASES) {
const sourceStyle = `${base.name}-${defaultStyle}`
for (const dir of directories) {
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`)
continue
}
rimraf.sync(toDir)
await fs.mkdir(toDir, { recursive: true })
const files = await fs.readdir(fromDir)
for (const file of files) {
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}`
)
}
}
}