Files
shadcn/apps/v4/scripts/build-registry.mts
zeekay 05fc512820 feat: sync upstream shadcn/ui — [email protected], v4 app, font system, chart improvements
Upstream sync from shadcn-ui/ui (1686 commits since last merge base).

Key changes:
- packages/shadcn upgraded to 4.1.0 (CLI font transformers, scaffold from github, chart colors)
- apps/v4 replaces apps/www as the primary docs/registry app
- Font system: 25+ heading font registries, font markers utility, @supports override
- Chart improvements: recharts v3 compat, radial chart fixes
- templates/ updated with monorepo variants (astro, vite, react-router, start)
- skills/shadcn AI skill definitions added
- deprecated/ removed (auth, auth-firebase, cli, www)

Preserved Hanzo-only paths: app/, pkg/, demo/, docs/, template/next/
2026-03-24 19:04:31 -07:00

775 lines
24 KiB
TypeScript

import { spawn } from "child_process"
import { promises as fs } from "fs"
import path from "path"
import { rimraf } from "rimraf"
import { registrySchema, type RegistryItem } from "shadcn/schema"
import {
createStyleMap,
transformDirection,
transformIcons,
transformStyle,
} from "shadcn/utils"
import { Project, ScriptKind } from "ts-morph"
import { getAllBlocks } from "@/lib/blocks"
import { legacyStyles } from "@/registry/_legacy-styles"
import { BASE_COLORS } from "@/registry/base-colors"
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[] = []
// Create a ts-morph project for icon transformations.
const iconProject = new Project({
compilerOptions: {},
})
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))
// Format base files before building styles so the JSON output contains formatted code.
const baseDirs = Array.from(BASES).flatMap((base) =>
STYLES.map((style) =>
path.join(process.cwd(), `registry/${base.name}-${style.name}`)
)
)
console.log("\n✨ Formatting base files...")
await batchPrettier(baseDirs)
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/index.json...")
await buildIndex()
console.log("\nšŸ“‹ Building public/r/registries.json...")
await buildRegistriesJson()
console.log("\nšŸŽØ Building public/r/colors...")
await buildColors()
// 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 applyIconTransform(content: string, filename: string) {
const sourceFile = iconProject.createSourceFile(filename, content, {
scriptKind: ScriptKind.TSX,
overwrite: true,
})
// Create a minimal config with just iconLibrary.
// transformIcons only uses config.iconLibrary, so we can safely cast this.
type TransformIconsConfig = Parameters<typeof transformIcons>[0]["config"]
type IconTransformInput = {
filename: string
raw: string
sourceFile: typeof sourceFile
config: TransformIconsConfig
}
const config = { iconLibrary: "lucide" } as TransformIconsConfig
await (transformIcons as (opts: IconTransformInput) => Promise<unknown>)({
filename,
raw: content,
sourceFile,
config,
})
return sourceFile.getText()
}
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}/`
)
// Transform icons for TSX files.
if (file.endsWith(".tsx")) {
content = await applyIconTransform(content, file)
}
await fs.writeFile(targetPath, content)
})
)
console.log(
` āœ… registry/${sourceStyle}/${dir} → examples/${base.name}/${dir}`
)
})
)
}
async function buildIndex() {
// Import ui/_registry.ts from each base.
const baseUiRegistries = await Promise.all(
Array.from(BASES).map(async (base) => {
const { ui } = await import(
`../registry/bases/${base.name}/ui/_registry.ts`
)
return { baseName: base.name, items: ui as RegistryItem[] }
})
)
// Dedupe components by name and merge links across bases.
type IndexItem = Omit<RegistryItem, "meta"> & {
meta?: { links?: Record<string, RegistryItem["meta"]> }
}
const componentMap = new Map<string, IndexItem>()
for (const { baseName, items } of baseUiRegistries) {
for (const item of items) {
if (!componentMap.has(item.name)) {
const { meta, ...rest } = item
componentMap.set(item.name, {
...rest,
...(meta?.links
? { meta: { links: { [baseName]: meta.links } } }
: {}),
})
} else if (item.meta?.links) {
const existing = componentMap.get(item.name)!
existing.meta = existing.meta || {}
existing.meta.links = existing.meta.links || {}
existing.meta.links[baseName] = item.meta.links
}
}
}
// Sort alphabetically by name.
const index = Array.from(componentMap.values()).sort((a, b) =>
a.name.localeCompare(b.name)
)
const outputPath = path.join(process.cwd(), "public/r/index.json")
await fs.writeFile(outputPath, JSON.stringify(index, null, 2))
prettierPaths.push(outputPath)
}
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 prettierBin = path.join(process.cwd(), "node_modules/.bin/prettier")
const proc = spawn(prettierBin, ["--write", ...paths], {
cwd: process.cwd(),
stdio: "inherit",
})
proc.on("close", () => resolve())
proc.on("error", reject)
})
}
async function buildColors() {
const colorsTargetPath = path.join(process.cwd(), "public/r/colors")
await fs.mkdir(colorsTargetPath, { recursive: true })
await Promise.all(
BASE_COLORS.map(async (baseColor) => {
const light = (baseColor.cssVars?.light ?? {}) as Record<string, string>
const dark = (baseColor.cssVars?.dark ?? {}) as Record<string, string>
const cssVarKeys = Object.keys(light).filter(
(key) => !key.startsWith("sidebar")
)
const rootVars = cssVarKeys
.map((key) => ` --${key}: ${light[key]};`)
.join("\n")
const darkVars = cssVarKeys
.filter((key) => dark[key])
.map((key) => ` --${key}: ${dark[key]};`)
.join("\n")
const payload = {
inlineColors: { light, dark },
cssVars: { light, dark },
cssVarsV4: baseColor.cssVars,
inlineColorsTemplate:
"@tailwind base;\n@tailwind components;\n@tailwind utilities;\n ",
cssVarsTemplate: `@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n :root {\n${rootVars}\n }\n\n .dark {\n${darkVars}\n }\n}\n\n@layer base {\n * {\n @apply border-border;\n }\n body {\n @apply bg-background text-foreground;\n }\n}`,
}
const outputPath = path.join(colorsTargetPath, `${baseColor.name}.json`)
await fs.writeFile(outputPath, JSON.stringify(payload, null, 2))
prettierPaths.push(outputPath)
console.log(` āœ… ${baseColor.name}.json`)
})
)
}