Files
shadcn-ui/apps/v4/scripts/build-registry.mts
shadcn 8da4592308 feat: update registry build commands (#10880)
* feat: update registry build commands

* fix
2026-06-06 23:19:46 +04:00

1586 lines
44 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 { createHash } from "crypto"
import { promises as fs } from "fs"
import { createRequire } from "module"
import { availableParallelism } from "os"
import path from "path"
import { fileURLToPath } from "url"
import { parseArgs } from "util"
import prettier from "prettier"
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"
/*
* build-registry.mts is the single v4 registry pipeline.
*
* Source of truth:
* - Authored raw component/registry source lives in registry/bases/base and
* registry/bases/radix.
* - Authored demo source lives in examples/base and examples/radix.
* - Style tokens live in registry/styles/style-*.css.
*
* Persistent outputs:
* - registry/bases/__index__.tsx
* - registry/__index__.tsx
* - examples/__index__.tsx
* - styles/<base-style>/ui/*
* - styles/<base-style>/ui-rtl/* for base-nova and radix-nova only
* - public/r/*
*
* Temporary outputs:
* - registry/<base-style>/*
* - registry-<style>.json
*
* Execution order:
* 1. Build registry/bases/__index__.tsx from the authored base registries.
* 2. Build temporary styled registries under registry/<base-style>.
* 3. Build registry/__index__.tsx for runtime lookup across legacy styles and
* generated base-style combinations.
* 4. Build examples/__index__.tsx from authored demos.
* 5. Export public/r/* for every style through the shadcn CLI.
* 6. Copy compiled ui/* from the temporary registries into styles/<style>/ui.
* 7. Build styles/<style>/ui-rtl for base-nova and radix-nova only.
* 8. Format the generated persistent outputs.
* 9. Clean up the temporary registry/<base-style> trees and registry-*.json.
*
* Targeted modes (see parseBuildOptions):
* - --examples rebuilds examples/__index__.tsx only.
* - --indexes rebuilds the runtime registry indexes only.
* - --style <style|all> rebuilds local styles/<style>/ui (+ ui-rtl).
* - --registry <style|all> rebuilds installable public/r/styles/<style>.
* Running with no options performs the full build described above.
*/
const STYLE_COMBINATIONS = Array.from(BASES).flatMap((base) =>
STYLES.map((style) => ({
base,
style,
name: `${base.name}-${style.name}`,
title: `${base.title} ${style.title}`,
}))
)
const CPU_COUNT = availableParallelism()
const STYLE_BUILD_CONCURRENCY = Math.max(1, Math.min(CPU_COUNT, 4))
const FILE_BUILD_CONCURRENCY = Math.max(4, Math.min(CPU_COUNT, 8))
const COPY_CONCURRENCY = Math.max(4, Math.min(CPU_COUNT, 8))
const CLI_BUILD_CONCURRENCY = Math.max(
1,
Math.min(Math.floor(CPU_COUNT / 2), 4)
)
const TRANSFORM_CACHE_VERSION = "3"
const CACHE_ROOT = path.join(
process.cwd(),
"node_modules/.cache/build-registry"
)
const TRANSFORM_CACHE_ROOT = path.join(CACHE_ROOT, "transforms")
const TRANSFORM_CACHE_MANIFEST_PATH = path.join(
CACHE_ROOT,
"transform-manifest.json"
)
const GENERATED_REGISTRY_CACHE_PATHS = new Set([
"registry/__blocks__.json",
"registry/__index__.tsx",
"registry/bases/__index__.tsx",
])
type TransformCacheManifestEntry = {
inputHash: string
outputHash: string
}
const transformCacheManifest = new Map<string, TransformCacheManifestEntry>()
let transformCacheDirty = false
let prettierConfigPromise: Promise<prettier.Options | null> | null = null
// Generated output is prettier-formatted in the full (prod) build. Targeted dev
// builds skip formatting for speed; the next full build re-canonicalizes
// everything. The transform cache always stores formatted content (see
// getCachedStyledContent), so a full build never reads an unformatted entry.
let shouldFormatOutput = true
const resolveFromScript = createRequire(import.meta.url).resolve
const iconProject = new Project({
compilerOptions: {},
})
function getStylesToBuild() {
const stylesToBuild = new Map<string, { name: string; title: string }>()
for (const style of legacyStyles) {
stylesToBuild.set(style.name, style)
}
for (const style of STYLE_COMBINATIONS) {
stylesToBuild.set(style.name, {
name: style.name,
title: style.title,
})
}
return Array.from(stylesToBuild.values())
}
function getStyleCombination(styleName: string) {
return STYLE_COMBINATIONS.find((style) => style.name === styleName) ?? null
}
type BuildOptions = {
examples: boolean
indexes: boolean
style: "all" | string | null
registry: "all" | string | null
}
const USAGE = `Usage: registry:build [options]
Run with no options for a full registry build, or target a single artifact:
--examples Rebuild examples/__index__.tsx only.
--indexes Rebuild the runtime registry indexes only.
--style <style|all> Rebuild local generated style files under styles/<style>/ui.
--registry <style|all> Rebuild installable registry JSON under public/r/styles/<style>.
<style> must be "all" or a known final style id (e.g. base-nova, radix-nova, base-sera, new-york-v4).
Flags can be combined, e.g. --style base-nova --registry base-nova.`
function getKnownStyleNames() {
return new Set(getStylesToBuild().map((style) => style.name))
}
function assertKnownTarget(flag: "--style" | "--registry", target: string) {
if (target === "all") {
return
}
const knownStyleNames = getKnownStyleNames()
if (!knownStyleNames.has(target)) {
const valid = ["all", ...Array.from(knownStyleNames)].join(", ")
throw new Error(
`Unknown ${flag} target "${target}". Valid targets: ${valid}.\n\n${USAGE}`
)
}
}
function parseBuildOptions(argv: string[]): BuildOptions {
let values: {
examples?: boolean
indexes?: boolean
style?: string
registry?: string
}
try {
;({ values } = parseArgs({
args: argv,
options: {
examples: { type: "boolean" },
indexes: { type: "boolean" },
style: { type: "string" },
registry: { type: "string" },
},
allowPositionals: false,
strict: true,
}))
} catch (error) {
throw new Error(`${(error as Error).message}\n\n${USAGE}`)
}
if (values.style !== undefined) {
assertKnownTarget("--style", values.style)
}
if (values.registry !== undefined) {
assertKnownTarget("--registry", values.registry)
}
return {
examples: values.examples ?? false,
indexes: values.indexes ?? false,
style: values.style ?? null,
registry: values.registry ?? null,
}
}
function isFullBuild(options: BuildOptions) {
return (
!options.examples &&
!options.indexes &&
options.style === null &&
options.registry === null
)
}
function getTargetStyles(target: "all" | string | null) {
const stylesToBuild = getStylesToBuild()
if (target === "all") {
return stylesToBuild
}
return stylesToBuild.filter((style) => style.name === target)
}
function stripFileExtension(filePath: string) {
return filePath.replace(/\.(tsx|ts|json|mdx)$/, "")
}
function normalizeRegistryFiles(item: RegistryItem): Array<{
path: string
type: string
target?: string
}> {
return (
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,
})) ?? []
)
}
function shouldGenerateRtlStyles(styleName: string) {
return styleName === "base-nova" || styleName === "radix-nova"
}
function getTemporaryRegistryRoot(styleName: string) {
return path.join(process.cwd(), `registry/${styleName}`)
}
function getPersistentStyleRoot(styleName: string) {
return path.join(process.cwd(), "styles", styleName)
}
function hashContent(...parts: string[]) {
const hash = createHash("sha256")
for (const part of parts) {
hash.update(part)
hash.update("\0")
}
return hash.digest("hex")
}
async function getTransformCacheHash() {
const [implementationHash, registryHash] = await Promise.all([
getTransformImplementationHash(),
getAuthoredRegistryHash(),
])
return hashContent(implementationHash, registryHash)
}
async function getTransformImplementationHash() {
const dependencyFiles = [
fileURLToPath(import.meta.url),
resolveFromScript("shadcn/utils"),
path.resolve(process.cwd(), "../../pnpm-lock.yaml"),
]
const dependencyContent = await Promise.all(
dependencyFiles.map(async (filePath) => {
const content = await readFileIfExists(filePath)
const relativePath = toPosixPath(path.relative(process.cwd(), filePath))
return `${relativePath}\0${content ?? "missing"}`
})
)
return hashContent(...dependencyContent)
}
async function getAuthoredRegistryHash() {
const registryRoot = path.join(process.cwd(), "registry")
const filePaths = await getCacheableRegistryFiles(registryRoot)
const fileContent = await Promise.all(
filePaths.map(async (filePath) => {
const relativePath = toPosixPath(path.relative(process.cwd(), filePath))
const content = await fs.readFile(filePath, "utf8")
return `${relativePath}\0${content}`
})
)
return hashContent(...fileContent)
}
async function getCacheableRegistryFiles(dirPath: string): Promise<string[]> {
const entries = await readDirectoryEntries(dirPath)
const files = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(dirPath, entry.name)
const relativePath = toPosixPath(path.relative(process.cwd(), entryPath))
if (shouldSkipRegistryCachePath(relativePath)) {
return []
}
if (entry.isDirectory()) {
return getCacheableRegistryFiles(entryPath)
}
if (!entry.isFile()) {
return []
}
return [entryPath]
})
)
return files.flat().sort((a, b) => a.localeCompare(b))
}
function shouldSkipRegistryCachePath(relativePath: string) {
if (GENERATED_REGISTRY_CACHE_PATHS.has(relativePath)) {
return true
}
return STYLE_COMBINATIONS.some((style) =>
relativePath.startsWith(`registry/${style.name}/`)
)
}
function toPosixPath(filePath: string) {
return filePath.split(path.sep).join("/")
}
async function readFileIfExists(filePath: string) {
try {
return await fs.readFile(filePath, "utf8")
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return null
}
throw error
}
}
async function writeIfChanged(filePath: string, content: string) {
const existingContent = await readFileIfExists(filePath)
if (existingContent === content) {
return false
}
await fs.mkdir(path.dirname(filePath), { recursive: true })
await fs.writeFile(filePath, content)
return true
}
async function formatSource(content: string, filePath: string) {
prettierConfigPromise ??= prettier.resolveConfig(
path.join(process.cwd(), "package.json")
)
const prettierConfig = (await prettierConfigPromise) ?? {}
return prettier.format(content, {
...prettierConfig,
filepath: filePath,
})
}
async function formatGeneratedSource(content: string, filePath: string) {
if (!shouldFormatOutput) {
return content
}
return formatSource(content, filePath)
}
async function formatGeneratedJson(value: unknown, filePath: string) {
return formatGeneratedSource(JSON.stringify(value, null, 2), filePath)
}
async function loadTransformCache() {
const existingManifest = await readFileIfExists(TRANSFORM_CACHE_MANIFEST_PATH)
if (!existingManifest) {
return
}
const payload = JSON.parse(existingManifest) as Record<string, unknown>
for (const [key, value] of Object.entries(payload)) {
if (isTransformCacheManifestEntry(value)) {
transformCacheManifest.set(key, value)
}
}
}
function isTransformCacheManifestEntry(
value: unknown
): value is TransformCacheManifestEntry {
return (
typeof value === "object" &&
value !== null &&
"inputHash" in value &&
"outputHash" in value &&
typeof value.inputHash === "string" &&
typeof value.outputHash === "string"
)
}
async function saveTransformCache() {
if (!transformCacheDirty) {
return
}
await fs.mkdir(CACHE_ROOT, { recursive: true })
const payload = Object.fromEntries(
Array.from(transformCacheManifest.entries()).sort(([a], [b]) =>
a.localeCompare(b)
)
)
await fs.writeFile(
TRANSFORM_CACHE_MANIFEST_PATH,
JSON.stringify(payload, null, 2)
)
transformCacheDirty = false
}
async function getCachedStyledContent({
styleName,
baseName,
filePath,
source,
styleHash,
transformCacheHash,
styleMap,
}: {
styleName: string
baseName: string
filePath: string
source: string
styleHash: string
transformCacheHash: string
styleMap: Record<string, string>
}) {
const cacheKey = `${styleName}:${filePath}`
const cachePath = path.join(TRANSFORM_CACHE_ROOT, styleName, filePath)
const inputHash = hashContent(
TRANSFORM_CACHE_VERSION,
styleName,
baseName,
filePath,
transformCacheHash,
styleHash,
source
)
const cachedEntry = transformCacheManifest.get(cacheKey)
if (cachedEntry?.inputHash === inputHash) {
const cachedContent = await readFileIfExists(cachePath)
if (
cachedContent !== null &&
hashContent(cachedContent) === cachedEntry.outputHash
) {
return cachedContent
}
}
let transformedContent = await transformStyle(source, { styleMap })
transformedContent = transformedContent.replace(
new RegExp(`@/registry/bases/${baseName}/`, "g"),
`@/registry/${styleName}/`
)
// Always format cached content so a later full build never reads an
// unformatted entry produced by a targeted dev build.
transformedContent = await formatSource(
transformedContent,
path.join(getTemporaryRegistryRoot(styleName), filePath)
)
await fs.mkdir(path.dirname(cachePath), { recursive: true })
await fs.writeFile(cachePath, transformedContent)
const outputHash = hashContent(transformedContent)
const nextEntry = { inputHash, outputHash }
if (
cachedEntry?.inputHash !== nextEntry.inputHash ||
cachedEntry?.outputHash !== nextEntry.outputHash
) {
transformCacheManifest.set(cacheKey, nextEntry)
transformCacheDirty = true
}
return transformedContent
}
async function runWithConcurrency<T, R>(
items: T[],
limit: number,
worker: (item: T, index: number) => Promise<R>
) {
const results = new Array<R>(items.length)
let currentIndex = 0
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, async () => {
while (true) {
const index = currentIndex++
if (index >= items.length) {
return
}
results[index] = await worker(items[index], index)
}
})
)
return results
}
try {
const totalStart = performance.now()
const options = parseBuildOptions(process.argv.slice(2))
if (isFullBuild(options)) {
await runFullBuild()
} else {
await runTargetedBuild(options)
}
const elapsed = ((performance.now() - totalStart) / 1000).toFixed(2)
console.log(`\n✅ Build complete in ${elapsed}s!`)
} catch (error) {
await saveTransformCache().catch(console.error)
console.error(error)
process.exit(1)
}
async function runFullBuild() {
await loadTransformCache()
console.log("\n🏗 Building bases...")
await buildBasesIndex(Array.from(BASES))
await buildBases(Array.from(BASES))
const stylesToBuild = getStylesToBuild()
console.log("\n📦 Building registry/__index__.tsx...")
await buildRegistryIndex(stylesToBuild)
console.log("\n📋 Building examples/__index__.tsx...")
await buildExamplesIndex()
console.log("\n💅 Building styles...")
await runWithConcurrency(
stylesToBuild,
CLI_BUILD_CONCURRENCY,
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()
console.log("\n📦 Building public/r/index.json...")
await buildIndex()
console.log("\n🎨 Building public/r/colors...")
await buildColors()
console.log("\n📋 Copying compiled ui to styles...")
await copyUIToStyles()
console.log("\n🔄 Building RTL styles...")
await buildRtlStyles()
console.log("\n🧹 Cleaning up...")
await cleanUpTemporaryFiles(stylesToBuild.map((style) => style.name))
await saveTransformCache()
}
async function runTargetedBuild(options: BuildOptions) {
// Targeted builds are for quick dev iteration: skip prettier on generated
// output. The full (prod) build re-formats everything to its canonical state.
shouldFormatOutput = false
await loadTransformCache()
// Phases run in dependency-safe order: indexes and examples write the runtime
// lookup files first, the targeted style build copies compiled ui into
// styles/<style>, and the targeted registry build exports public/r last.
if (options.indexes) {
await runIndexesBuild()
}
if (options.examples) {
await runExamplesBuild()
}
if (options.style !== null) {
await runTargetedStyleBuild(options.style)
}
if (options.registry !== null) {
await runTargetedRegistryBuild(options.registry)
}
await saveTransformCache()
}
async function runIndexesBuild() {
console.log("🏗️ Building registry/bases/__index__.tsx...")
await buildBasesIndex(Array.from(BASES))
// buildBlocksIndex imports @/registry/__index__, so the registry index must
// be regenerated before the blocks index.
console.log("\n📦 Building registry/__index__.tsx...")
await buildRegistryIndex(getStylesToBuild())
console.log("\n🗂 Building registry/__blocks__.json...")
await buildBlocksIndex()
console.log("\n📦 Building public/r/index.json...")
await buildIndex()
}
async function runExamplesBuild() {
console.log("📋 Building examples/__index__.tsx...")
await buildExamplesIndex()
}
async function runTargetedStyleBuild(target: "all" | string) {
if (target !== "all" && !getStyleCombination(target)) {
throw new Error(
`--style ${target} is not supported because it is a legacy source registry. Use --registry ${target}.`
)
}
// styles/<style>/ui only exists for generated base/style combinations, so we
// skip legacy source styles (e.g. new-york-v4) when targeting "all".
const targetStyles = getTargetStyles(target).filter((style) =>
getStyleCombination(style.name)
)
const targetStyleNames = new Set(targetStyles.map((style) => style.name))
if (targetStyleNames.size === 0) {
console.log(" No generated styles to build.")
return
}
console.log("💅 Building styles...")
await buildBases(Array.from(BASES), targetStyleNames)
console.log("\n📋 Copying compiled ui to styles...")
await copyUIToStyles(targetStyleNames)
console.log("\n🔄 Building RTL styles...")
await buildRtlStyles(targetStyleNames)
console.log("\n🧹 Cleaning up...")
await cleanUpTemporaryFiles(Array.from(targetStyleNames))
}
async function runTargetedRegistryBuild(target: "all" | string) {
const targetStyles = getTargetStyles(target)
const comboStyleNames = new Set(
targetStyles
.filter((style) => getStyleCombination(style.name))
.map((style) => style.name)
)
// Only generated base/style combinations need a temporary registry/<style>
// tree. Legacy source styles (e.g. new-york-v4) already ship registry.ts.
if (comboStyleNames.size > 0) {
console.log("🏗️ Building bases...")
await buildBases(Array.from(BASES), comboStyleNames)
}
console.log("\n💅 Building registry...")
await runWithConcurrency(
targetStyles,
CLI_BUILD_CONCURRENCY,
async (style) => {
await buildRegistryJsonFile(style.name)
await buildRegistry(style.name)
console.log(`${style.name}`)
}
)
console.log("\n🧹 Cleaning up...")
await cleanUpTemporaryFiles(targetStyles.map((style) => style.name))
}
async function buildBasesIndex(bases: Base[]) {
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) {
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) {
if (item.type === "registry:internal") {
continue
}
const files = normalizeRegistryFiles(item)
if (files.length === 0) {
continue
}
const componentPath = files[0]?.path
? `@/registry/bases/${base.name}/${stripFileExtension(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 += `
}`
const outputPath = path.join(process.cwd(), "registry/bases/__index__.tsx")
await writeIfChanged(
outputPath,
await formatGeneratedSource(index, outputPath)
)
}
async function buildBases(bases: Base[], targetStyleNames?: Set<string>) {
// For targeted builds, only load bases that contribute a requested
// combination. Otherwise a single-base target (e.g. --style base-nova) would
// still import and read every source file for the other base.
const basesToBuild = targetStyleNames
? bases.filter((base) =>
STYLES.some((style) =>
targetStyleNames.has(`${base.name}-${style.name}`)
)
)
: bases
const [baseImports, styleMaps, transformCacheHash] = await Promise.all([
Promise.all(
basesToBuild.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"
)
const sourceFilePaths = Array.from(
new Set(
registryItems.flatMap((item) =>
normalizeRegistryFiles(item).map((file) => file.path)
)
)
)
const sourceFiles = new Map(
await Promise.all(
sourceFilePaths.map(
async (filePath): Promise<readonly [string, string]> =>
[
filePath,
await fs.readFile(
path.join(
process.cwd(),
`registry/bases/${base.name}/${filePath}`
),
"utf8"
),
] as const
)
)
)
return { base, baseRegistry, registryItems, sourceFiles }
})
),
Promise.all(
STYLES.map(async (style) => {
const styleContent = await fs.readFile(
path.join(process.cwd(), `registry/styles/style-${style.name}.css`),
"utf8"
)
return {
style,
styleHash: hashContent(styleContent),
styleMap: createStyleMap(styleContent),
}
})
),
getTransformCacheHash(),
])
const combinations: Array<{
base: Base
style: (typeof STYLES)[number]
baseRegistry: (typeof baseImports)[number]["baseRegistry"]
registryItems: (typeof baseImports)[number]["registryItems"]
sourceFiles: (typeof baseImports)[number]["sourceFiles"]
styleHash: string
transformCacheHash: string
styleMap: Record<string, string>
}> = []
for (const {
base,
baseRegistry,
registryItems,
sourceFiles,
} of baseImports) {
for (const { style, styleHash, styleMap } of styleMaps) {
const styleName = `${base.name}-${style.name}`
if (targetStyleNames && !targetStyleNames.has(styleName)) {
continue
}
combinations.push({
base,
style,
baseRegistry,
registryItems,
sourceFiles,
styleHash,
transformCacheHash,
styleMap,
})
}
}
await runWithConcurrency(
combinations,
STYLE_BUILD_CONCURRENCY,
async ({
base,
style,
baseRegistry,
registryItems,
sourceFiles,
styleHash,
transformCacheHash,
styleMap,
}) => {
const styleName = `${base.name}-${style.name}`
const styleOutputDir = getTemporaryRegistryRoot(styleName)
console.log(`${styleName}...`)
await rimraf(styleOutputDir)
await fs.mkdir(styleOutputDir, { recursive: true })
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 filesToBuild = registryItems.flatMap((registryItem) =>
normalizeRegistryFiles(registryItem)
)
await runWithConcurrency(
filesToBuild,
FILE_BUILD_CONCURRENCY,
async (file) => {
const source = sourceFiles.get(file.path)
if (typeof source !== "string") {
throw new Error(
`Missing cached source for ${base.name}/${file.path}`
)
}
const fileExtension = path.extname(file.path)
const shouldTransform =
fileExtension === ".tsx" || fileExtension === ".ts"
const transformedContent = shouldTransform
? await getCachedStyledContent({
styleName,
baseName: base.name,
filePath: file.path,
source,
styleHash,
transformCacheHash,
styleMap,
})
: source
const outputPath = path.join(styleOutputDir, file.path)
await fs.mkdir(path.dirname(outputPath), { recursive: true })
await fs.writeFile(outputPath, transformedContent)
}
)
}
)
}
async function buildExamplesIndex() {
const examplesDir = path.join(process.cwd(), "examples")
const baseResults = await Promise.all(
Array.from(BASES).map(async (base) => {
const baseDir = path.join(examplesDir, base.name)
try {
await fs.access(baseDir)
} catch {
console.log(` Skipping ${base.name} - directory does not exist`)
return null
}
const allEntries = await fs.readdir(baseDir, { withFileTypes: true })
const files = allEntries
.filter((entry) => entry.isFile() && entry.name.endsWith(".tsx"))
.map((entry) => entry.name)
.sort()
console.log(` Found ${files.length} demos for ${base.name}`)
return { baseName: base.name, files }
})
)
let index = `// @ts-nocheck
// This file is autogenerated by scripts/build-registry.mts
// Do not edit this file directly.
import * as React from "react"
export const ExamplesIndex: Record<string, Record<string, any>> = {`
for (const result of baseResults) {
if (!result) continue
const { baseName, files } = result
index += `
"${baseName}": {`
for (const file of files) {
const name = file.replace(/\.tsx$/, "")
index += `
"${name}": {
name: "${name}",
filePath: "examples/${baseName}/${file}",
component: React.lazy(async () => {
const mod = await import("./${baseName}/${name}")
const exportName = Object.keys(mod).find(key => typeof mod[key] === 'function' || typeof mod[key] === 'object') || "${name}"
return { default: mod.default || mod[exportName] }
}),
},`
}
index += `
},`
}
index += `
}
`
const outputPath = path.join(examplesDir, "__index__.tsx")
await writeIfChanged(
outputPath,
await formatGeneratedSource(index, outputPath)
)
}
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) {
const styleCombination = getStyleCombination(style.name)
const { registry: importedRegistry } = styleCombination
? await import(
`../registry/bases/${styleCombination.base.name}/registry.ts`
)
: await import(`../registry/${style.name}/registry.ts`)
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
}
if (styleCombination && item.type !== "registry:ui") {
continue
}
const files = normalizeRegistryFiles(item)
if (files.length === 0) {
continue
}
const resolvedFiles = styleCombination
? files.map((file) => ({
...file,
path: file.path.startsWith("ui/")
? `styles/${style.name}/${file.path}`
: `registry/bases/${styleCombination.base.name}/${file.path}`,
}))
: files.map((file) => ({
...file,
path: `registry/${style.name}/${file.path}`,
}))
const componentPath = files[0]?.path
? styleCombination
? files[0].path.startsWith("ui/")
? `@/styles/${style.name}/${stripFileExtension(files[0].path)}`
: `@/registry/bases/${styleCombination.base.name}/${stripFileExtension(files[0].path)}`
: `@/registry/${style.name}/${stripFileExtension(files[0].path)}`
: ""
index += `
"${item.name}": {
name: "${item.name}",
title: "${item.title}",
description: "${item.description ?? ""}",
type: "${item.type}",
registryDependencies: ${JSON.stringify(item.registryDependencies)},
files: [${resolvedFiles.map((file) => {
return `{
path: "${file.path}",
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 += `
}`
const outputPath = path.join(process.cwd(), "registry/__index__.tsx")
await writeIfChanged(
outputPath,
await formatGeneratedSource(index, outputPath)
)
}
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 = normalizeRegistryFiles(item).map((file) => ({
...file,
path: `registry/${styleName}/${file.path}`,
}))
return files.length > 0 ? { ...item, files } : item
}),
}
const outputDir = path.join(process.cwd(), `public/r/styles/${styleName}`)
await fs.mkdir(outputDir, { recursive: true })
const registryJsonPath = path.join(outputDir, "registry.json")
const fixedRegistryJson = await formatGeneratedJson(
fixedRegistry,
registryJsonPath
)
await writeIfChanged(registryJsonPath, fixedRegistryJson)
const tempRegistryPath = path.join(
process.cwd(),
`registry-${styleName}.json`
)
await fs.writeFile(tempRegistryPath, fixedRegistryJson)
}
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,
}))
const blocksJsonPath = path.join(process.cwd(), "registry/__blocks__.json")
await writeIfChanged(
blocksJsonPath,
await formatGeneratedJson(payload, blocksJsonPath)
)
}
async function cleanUpTemporaryFiles(styleNames: string[]) {
const cleanupTasks: Promise<boolean>[] = []
for (const styleName of styleNames) {
cleanupTasks.push(
rimraf(path.join(process.cwd(), `registry-${styleName}.json`))
)
// Only generated combinations have a temporary registry/<style> tree.
// Legacy source styles (e.g. new-york-v4) own registry/<style> and must
// never be removed.
if (getStyleCombination(styleName)) {
console.log(` 🗑️ registry/${styleName}`)
cleanupTasks.push(rimraf(getTemporaryRegistryRoot(styleName)))
}
}
await Promise.all(cleanupTasks)
}
async function buildConfig() {
const config = { presets: PRESETS }
const outputPath = path.join(process.cwd(), "public/r/config.json")
await writeIfChanged(
outputPath,
await formatGeneratedJson(config, outputPath)
)
}
async function applyIconTransform(content: string, filename: string) {
if (!content.includes("IconPlaceholder")) {
return content
}
const sourceFile = iconProject.createSourceFile(filename, content, {
scriptKind: ScriptKind.TSX,
overwrite: true,
})
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 copyUIToStyles(targetStyleNames?: Set<string>) {
const styleCombinations = targetStyleNames
? STYLE_COMBINATIONS.filter((style) => targetStyleNames.has(style.name))
: STYLE_COMBINATIONS
await runWithConcurrency(
styleCombinations,
COPY_CONCURRENCY,
async ({ name: styleName }) => {
const sourceDir = path.join(getTemporaryRegistryRoot(styleName), "ui")
const styleRoot = getPersistentStyleRoot(styleName)
const targetDir = path.join(styleRoot, "ui")
try {
await fs.access(sourceDir)
} catch {
console.log(` ⚠️ registry/${styleName}/ui not found, skipping`)
return
}
await syncDirectory({
fromDir: sourceDir,
toDir: targetDir,
transformContent: async (content, filePath, targetPath) => {
let nextContent = rewriteRegistryUiImportsToStyle(content, styleName)
if (filePath.endsWith(".tsx")) {
nextContent = await applyIconTransform(
nextContent,
path.basename(filePath)
)
}
if (targetPath.endsWith(".ts") || targetPath.endsWith(".tsx")) {
return formatGeneratedSource(nextContent, targetPath)
}
return nextContent
},
})
if (!shouldGenerateRtlStyles(styleName)) {
await rimraf(path.join(styleRoot, "ui-rtl"))
}
console.log(` ✅ registry/${styleName}/ui → styles/${styleName}/ui`)
}
)
}
async function buildRtlStyles(targetStyleNames?: Set<string>) {
await runWithConcurrency(
STYLE_COMBINATIONS.filter(
(style) =>
shouldGenerateRtlStyles(style.name) &&
(!targetStyleNames || targetStyleNames.has(style.name))
),
COPY_CONCURRENCY,
async ({ name: styleName }) => {
const sourceDir = path.join(getPersistentStyleRoot(styleName), "ui")
const targetDir = path.join(getPersistentStyleRoot(styleName), "ui-rtl")
try {
await fs.access(sourceDir)
} catch {
console.log(` ⚠️ styles/${styleName}/ui not found, skipping`)
return
}
await syncDirectory({
fromDir: sourceDir,
toDir: targetDir,
transformContent: async (content, filePath, targetPath) => {
if (!filePath.endsWith(".ts") && !filePath.endsWith(".tsx")) {
return content
}
return formatGeneratedSource(
rewriteStyleDirectionImports(
await transformDirection(content, true),
styleName
),
targetPath
)
},
})
console.log(` ✅ styles/${styleName}/ui-rtl`)
}
)
}
async function buildIndex() {
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[] }
})
)
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
}
}
}
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 writeIfChanged(outputPath, await formatGeneratedJson(index, outputPath))
}
async function readDirectoryEntries(dirPath: string) {
try {
return await fs.readdir(dirPath, { withFileTypes: true })
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return []
}
throw error
}
}
async function syncDirectory({
fromDir,
toDir,
transformContent,
}: {
fromDir: string
toDir: string
transformContent?: (
content: string,
filePath: string,
targetPath: string
) => Promise<string>
}): Promise<string[]> {
await fs.mkdir(toDir, { recursive: true })
const [sourceEntries, targetEntries] = await Promise.all([
fs.readdir(fromDir, { withFileTypes: true }),
readDirectoryEntries(toDir),
])
const targetEntriesByName = new Map(
targetEntries.map((entry) => [entry.name, entry])
)
const sourceNames = new Set(sourceEntries.map((entry) => entry.name))
await Promise.all(
targetEntries.map(async (entry) => {
if (!sourceNames.has(entry.name)) {
await rimraf(path.join(toDir, entry.name))
}
})
)
const changedPaths: string[][] = await runWithConcurrency(
sourceEntries,
COPY_CONCURRENCY,
async (entry) => {
const sourcePath = path.join(fromDir, entry.name)
const targetPath = path.join(toDir, entry.name)
const existingTargetEntry = targetEntriesByName.get(entry.name)
if (entry.isDirectory()) {
if (existingTargetEntry && !existingTargetEntry.isDirectory()) {
await rimraf(targetPath)
}
return await syncDirectory({
fromDir: sourcePath,
toDir: targetPath,
transformContent,
})
}
if (existingTargetEntry?.isDirectory()) {
await rimraf(targetPath)
}
let content = await fs.readFile(sourcePath, "utf8")
if (transformContent) {
content = await transformContent(content, sourcePath, targetPath)
}
return (await writeIfChanged(targetPath, content)) ? [targetPath] : []
}
)
return changedPaths.flat()
}
function rewriteRegistryUiImportsToStyle(content: string, styleName: string) {
return content
.replaceAll(`@/registry/${styleName}/ui/`, `@/styles/${styleName}/ui/`)
.replaceAll(`@/registry/${styleName}/lib/utils`, `@/lib/utils`)
.replaceAll(
`@/registry/${styleName}/hooks/use-mobile`,
`@/hooks/use-mobile`
)
.replaceAll(`@/registry/${styleName}/lib/`, `@/lib/`)
.replaceAll(`@/registry/${styleName}/hooks/`, `@/hooks/`)
}
function rewriteStyleDirectionImports(content: string, styleName: string) {
return content.replaceAll(
`@/styles/${styleName}/ui/`,
`@/styles/${styleName}/ui-rtl/`
)
}
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 writeIfChanged(
outputPath,
await formatGeneratedJson(payload, outputPath)
)
console.log(`${baseColor.name}.json`)
})
)
}