import type { z } from 'zod' import type { Registry, RegistryEntry, registryItemTypeSchema, } from '../registry/schema' // @sts-nocheck import { existsSync, promises as fs } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { template } from 'lodash-es' import { rimraf } from 'rimraf' import { registry } from '../registry' import { buildRegistry as crawlContent } from '../registry/crawl-content' import { baseColors } from '../registry/registry-base-colors' import { colorMapping, colors } from '../registry/registry-colors' import { iconLibraries, icons } from '../registry/registry-icons' import { styles } from '../registry/registry-styles' import { registryEntrySchema, registrySchema, } from '../registry/schema' import { fixImport } from './fix-import' const REGISTRY_PATH = path.join(process.cwd(), 'src/public/r') const REGISTRY_INDEX_WHITELIST: z.infer[] = [ 'registry:ui', 'registry:lib', 'registry:hook', 'registry:theme', 'registry:block', 'registry:example', ] // const project = new Project({ // compilerOptions: {}, // }) async function createTempSourceFile(filename: string) { const dir = await fs.mkdtemp(path.join(tmpdir(), 'shadcn-')) return path.join(dir, filename) } // ---------------------------------------------------------------------------- // Build __registry__/index.ts. // ---------------------------------------------------------------------------- async function buildRegistry(registry: Registry) { let index = `// @ts-nocheck // This file is autogenerated by scripts/build-registry.ts // Do not edit this file directly. export const Index: Record = { ` for (const style of styles) { index += ` "${style.name}": {` // Build style index. for (const item of registry) { const resolveFiles = item.files?.map( file => `registry/${style.name}/${ typeof file === 'string' ? file : file.path }`, ) if (!resolveFiles) { continue } const type = item.type.split(':')[1] let sourceFilename = '' // const chunks: any = [] if (item.type === 'registry:block') { const file = resolveFiles[0] const filename = path.basename(file) let raw: string try { raw = await fs.readFile(file, 'utf8') } catch (error) { continue } // const tempFile = await createTempSourceFile(filename) // const sourceFile = project.createSourceFile(tempFile, raw, { // scriptKind: ScriptKind.TS, // }) // const description = sourceFile // .getVariableDeclaration('description') // ?.getInitializerOrThrow() // .asKindOrThrow(SyntaxKind.StringLiteral) // .getLiteralValue() // item.description = description ?? '' // // Find all imports. // const imports = new Map< // string, // { // module: string // text: string // isDefault?: boolean // } // >() // sourceFile.getImportDeclarations().forEach((node) => { // const module = node.getModuleSpecifier().getLiteralValue() // node.getNamedImports().forEach((item) => { // imports.set(item.getText(), { // module, // text: node.getText(), // }) // }) // const defaultImport = node.getDefaultImport() // if (defaultImport) { // imports.set(defaultImport.getText(), { // module, // text: defaultImport.getText(), // isDefault: true, // }) // } // }) // Find all opening tags with x-chunk attribute. // const components = sourceFile // .getDescendantsOfKind(SyntaxKind.JsxOpeningElement) // .filter((node) => { // return node.getAttribute('x-chunk') !== undefined // }) // chunks = await Promise.all( // components.map(async (component, index) => { // const chunkName = `${item.name}-chunk-${index}` // // Get the value of x-chunk attribute. // const attr = component // .getAttributeOrThrow('x-chunk') // .asKindOrThrow(SyntaxKind.JsxAttribute) // const description = attr // .getInitializerOrThrow() // .asKindOrThrow(SyntaxKind.StringLiteral) // .getLiteralValue() // // Delete the x-chunk attribute. // attr.remove() // // Add a new attribute to the component. // component.addAttribute({ // name: 'x-chunk', // initializer: `"${chunkName}"`, // }) // // Get the value of x-chunk-container attribute. // const containerAttr = component // .getAttribute('x-chunk-container') // ?.asKindOrThrow(SyntaxKind.JsxAttribute) // const containerClassName = containerAttr // ?.getInitializer() // ?.asKindOrThrow(SyntaxKind.StringLiteral) // .getLiteralValue() // containerAttr?.remove() // const parentJsxElement = component.getParentIfKindOrThrow( // SyntaxKind.JsxElement, // ) // // Find all opening tags on component. // const children = parentJsxElement // .getDescendantsOfKind(SyntaxKind.JsxOpeningElement) // .map((node) => { // return node.getTagNameNode().getText() // }) // .concat( // parentJsxElement // .getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement) // .map((node) => { // return node.getTagNameNode().getText() // }), // ) // const componentImports = new Map< // string, // string | string[] | Set // >() // children.forEach((child) => { // const importLine = imports.get(child) // if (importLine) { // const imports = componentImports.get(importLine.module) || [] // const newImports = importLine.isDefault // ? importLine.text // : new Set([...imports, child]) // componentImports.set( // importLine.module, // importLine?.isDefault ? newImports : Array.from(newImports), // ) // } // }) // const componnetImportLines = Array.from( // componentImports.keys(), // ).map((key) => { // const values = componentImports.get(key) // const specifier = Array.isArray(values) // ? `{${values.join(',')}}` // : values // return `import ${specifier} from "${key}"` // }) // const code = ` // 'use client' // ${componnetImportLines.join('\n')} // export default function Component() { // return (${parentJsxElement.getText()}) // }` // const targetFile = file.replace(item.name, `${chunkName}`) // const targetFilePath = path.join( // cwd(), // `registry/${style.name}/${type}/${chunkName}.ts`, // ) // // Write component file. // rimraf.sync(targetFilePath) // await fs.writeFile(targetFilePath, code, 'utf8') // return { // name: chunkName, // description, // component: `React.lazy(() => import("@/registry/${style.name}/${type}/${chunkName}")),`, // file: targetFile, // container: { // className: containerClassName, // }, // } // }), // ) // // Write the source file for blocks only. sourceFilename = `__registry__/${style.name}/${type}/${item.name}.ts` if (item.files) { const files = item.files.map(file => typeof file === 'string' ? { type: 'registry:page', path: file } : file, ) if (files?.length) { sourceFilename = `__registry__/${style.name}/${files[0].path}` } } const sourcePath = path.join(process.cwd(), sourceFilename) if (!existsSync(sourcePath)) { await fs.mkdir(sourcePath, { recursive: true }) } rimraf.sync(sourcePath) // await fs.writeFile(sourcePath, sourceFile.getText()) await fs.writeFile(sourcePath, raw) } let componentPath = `@/registry/${style.name}/${type}/${item.name}` if (item.files) { const files = item.files.map(file => typeof file === 'string' ? { type: 'registry:page', path: file } : file, ) if (files?.length) { componentPath = `@/registry/${style.name}/${files[0].path}` } } index += ` "${item.name}": { name: "${item.name}", description: "${item.description ?? ''}", type: "${item.type}", registryDependencies: ${JSON.stringify(item.registryDependencies)}, files: [${item.files?.map((file) => { const filePath = `registry/${style.name}/${ typeof file === 'string' ? file : file.path }` const resolvedFilePath = path.resolve(filePath) return typeof file === 'string' ? `"${resolvedFilePath}"` : `{ path: "${filePath}", type: "${file.type}", target: "${file.target ?? ''}" }` })}], component: React.lazy(() => import("${componentPath}")), source: "${sourceFilename}", category: "${item.category ?? ''}", subcategory: "${item.subcategory ?? ''}" },` } // TODO: maybe implement chunk? // chunks: [${chunks.map( // chunk => `{ // name: "${chunk.name}", // description: "${chunk.description ?? 'No description'}", // component: ${chunk.component} // file: "${chunk.file}", // container: { // className: "${chunk.container.className}" // } // }`, // )}] index += ` },` } index += ` } ` // ---------------------------------------------------------------------------- // Build registry/index.json. // ---------------------------------------------------------------------------- const items = registry .filter(item => ['registry:ui'].includes(item.type)) .map((item) => { return { ...item, files: item.files?.map((_file) => { const file = typeof _file === 'string' ? { path: _file, type: item.type, } : _file return file }), } }) const registryJson = JSON.stringify(items, null, 2) rimraf.sync(path.join(REGISTRY_PATH, 'index.json')) await fs.writeFile( path.join(REGISTRY_PATH, 'index.json'), registryJson, 'utf8', ) // Write style index. rimraf.sync(path.join(process.cwd(), '__registry__/index.ts')) await fs.writeFile(path.join(process.cwd(), '__registry__/index.ts'), index) } // ---------------------------------------------------------------------------- // Build registry/styles/[style]/[name].json. // ---------------------------------------------------------------------------- async function buildStyles(registry: Registry) { for (const style of styles) { const targetPath = path.join(REGISTRY_PATH, 'styles', style.name) // Create directory if it doesn't exist. if (!existsSync(targetPath)) { await fs.mkdir(targetPath, { recursive: true }) } for (const item of registry) { if (!REGISTRY_INDEX_WHITELIST.includes(item.type)) { continue } let files if (item.files) { files = await Promise.all( item.files.map(async (_file) => { const file = typeof _file === 'string' ? { path: _file, type: item.type, content: '', target: '', } : _file let content: string try { content = await fs.readFile( path.join(process.cwd(), 'registry', style.name, file.path), 'utf8', ) // Only fix imports for v0- blocks. if (item.name.startsWith('v0-')) { content = fixImport(content) } } catch (error) { return } // TODO: remove meta content // const tempFile = await createTempSourceFile(file.path) // const sourceFile = project.createSourceFile(tempFile, content, { // scriptKind: ScriptKind.TS, // }) // sourceFile.getVariableDeclaration('iframeHeight')?.remove() // sourceFile.getVariableDeclaration('containerClassName')?.remove() // sourceFile.getVariableDeclaration('description')?.remove() let target = file.target || '' if ((!target || target === '') && item.name.startsWith('v0-')) { const fileName = file.path.split('/').pop() if ( file.type === 'registry:block' || file.type === 'registry:component' || file.type === 'registry:example' ) { target = `components/${fileName}` } if (file.type === 'registry:ui') { target = `components/ui/${fileName}` } if (file.type === 'registry:hook') { target = `hooks/${fileName}` } if (file.type === 'registry:lib') { target = `lib/${fileName}` } } return { path: file.path, type: file.type, // content: sourceFile.getText(), content, target, } }), ) } const payload = registryEntrySchema .omit({ // source: true, category: true, subcategory: true, // chunks: true, }) .safeParse({ ...item, files, }) if (payload.success) { await fs.writeFile( path.join(targetPath, `${item.name}.json`), JSON.stringify(payload.data, null, 2), 'utf8', ) } } } // ---------------------------------------------------------------------------- // Build registry/styles/index.json. // ---------------------------------------------------------------------------- const stylesJson = JSON.stringify(styles, null, 2) await fs.writeFile( path.join(REGISTRY_PATH, 'styles/index.json'), stylesJson, 'utf8', ) } // ---------------------------------------------------------------------------- // Build registry/styles/[name]/index.json. // ---------------------------------------------------------------------------- async function buildStylesIndex() { for (const style of styles) { const targetPath = path.join(REGISTRY_PATH, 'styles', style.name) const dependencies = [ 'tailwindcss-animate', 'class-variance-authority', 'lucide-react', ] // TODO: Remove this when we migrate to lucide-react. // if (style.name === "new-york") { // dependencies.push("@radix-ui/react-icons") // } const payload: RegistryEntry = { name: style.name, type: 'registry:style', dependencies, registryDependencies: ['utils'], tailwind: { config: { plugins: [`require("tailwindcss-animate")`], }, }, cssVars: {}, files: [], } await fs.writeFile( path.join(targetPath, 'index.json'), JSON.stringify(payload, null, 2), 'utf8', ) } } // ---------------------------------------------------------------------------- // Build registry/colors/index.json. // ---------------------------------------------------------------------------- async function buildThemes() { const colorsTargetPath = path.join(REGISTRY_PATH, 'colors') rimraf.sync(colorsTargetPath) if (!existsSync(colorsTargetPath)) { await fs.mkdir(colorsTargetPath, { recursive: true }) } const colorsData: Record = {} for (const [color, value] of Object.entries(colors)) { if (typeof value === 'string') { colorsData[color] = value continue } if (Array.isArray(value)) { colorsData[color] = value.map(item => ({ ...item, rgbChannel: item.rgb.replace(/^rgb\((\d+),(\d+),(\d+)\)$/, '$1 $2 $3'), hslChannel: item.hsl.replace( /^hsl\(([\d.]+),([\d.]+%),([\d.]+%)\)$/, '$1 $2 $3', ), })) continue } if (typeof value === 'object') { colorsData[color] = { ...value, rgbChannel: value.rgb.replace(/^rgb\((\d+),(\d+),(\d+)\)$/, '$1 $2 $3'), hslChannel: value.hsl.replace( /^hsl\(([\d.]+),([\d.]+%),([\d.]+%)\)$/, '$1 $2 $3', ), } continue } } await fs.writeFile( path.join(colorsTargetPath, 'index.json'), JSON.stringify(colorsData, null, 2), 'utf8', ) // ---------------------------------------------------------------------------- // Build registry/colors/[base].json. // ---------------------------------------------------------------------------- const BASE_STYLES = `@tailwind base; @tailwind components; @tailwind utilities; ` const BASE_STYLES_WITH_VARIABLES = `@tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: <%- colors.light["background"] %>; --foreground: <%- colors.light["foreground"] %>; --card: <%- colors.light["card"] %>; --card-foreground: <%- colors.light["card-foreground"] %>; --popover: <%- colors.light["popover"] %>; --popover-foreground: <%- colors.light["popover-foreground"] %>; --primary: <%- colors.light["primary"] %>; --primary-foreground: <%- colors.light["primary-foreground"] %>; --secondary: <%- colors.light["secondary"] %>; --secondary-foreground: <%- colors.light["secondary-foreground"] %>; --muted: <%- colors.light["muted"] %>; --muted-foreground: <%- colors.light["muted-foreground"] %>; --accent: <%- colors.light["accent"] %>; --accent-foreground: <%- colors.light["accent-foreground"] %>; --destructive: <%- colors.light["destructive"] %>; --destructive-foreground: <%- colors.light["destructive-foreground"] %>; --border: <%- colors.light["border"] %>; --input: <%- colors.light["input"] %>; --ring: <%- colors.light["ring"] %>; --radius: 0.5rem; --chart-1: <%- colors.light["chart-1"] %>; --chart-2: <%- colors.light["chart-2"] %>; --chart-3: <%- colors.light["chart-3"] %>; --chart-4: <%- colors.light["chart-4"] %>; --chart-5: <%- colors.light["chart-5"] %>; } .dark { --background: <%- colors.dark["background"] %>; --foreground: <%- colors.dark["foreground"] %>; --card: <%- colors.dark["card"] %>; --card-foreground: <%- colors.dark["card-foreground"] %>; --popover: <%- colors.dark["popover"] %>; --popover-foreground: <%- colors.dark["popover-foreground"] %>; --primary: <%- colors.dark["primary"] %>; --primary-foreground: <%- colors.dark["primary-foreground"] %>; --secondary: <%- colors.dark["secondary"] %>; --secondary-foreground: <%- colors.dark["secondary-foreground"] %>; --muted: <%- colors.dark["muted"] %>; --muted-foreground: <%- colors.dark["muted-foreground"] %>; --accent: <%- colors.dark["accent"] %>; --accent-foreground: <%- colors.dark["accent-foreground"] %>; --destructive: <%- colors.dark["destructive"] %>; --destructive-foreground: <%- colors.dark["destructive-foreground"] %>; --border: <%- colors.dark["border"] %>; --input: <%- colors.dark["input"] %>; --ring: <%- colors.dark["ring"] %>; --chart-1: <%- colors.dark["chart-1"] %>; --chart-2: <%- colors.dark["chart-2"] %>; --chart-3: <%- colors.dark["chart-3"] %>; --chart-4: <%- colors.dark["chart-4"] %>; --chart-5: <%- colors.dark["chart-5"] %>; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } }` for (const baseColor of ['slate', 'gray', 'zinc', 'neutral', 'stone']) { const base: Record = { inlineColors: {}, cssVars: {}, } for (const [mode, values] of Object.entries(colorMapping)) { base.inlineColors[mode] = {} base.cssVars[mode] = {} for (const [key, value] of Object.entries(values)) { if (typeof value === 'string') { // Chart colors do not have a 1-to-1 mapping with tailwind colors. if (key.startsWith('chart-')) { base.cssVars[mode][key] = value continue } const resolvedColor = value.replace(/\{\{base\}\}-/g, `${baseColor}-`) base.inlineColors[mode][key] = resolvedColor const [resolvedBase, scale] = resolvedColor.split('-') const color = scale ? colorsData[resolvedBase].find( (item: any) => item.scale === Number.parseInt(scale), ) : colorsData[resolvedBase] if (color) { base.cssVars[mode][key] = color.hslChannel } } } } // Build css vars. base.inlineColorsTemplate = template(BASE_STYLES)({}) base.cssVarsTemplate = template(BASE_STYLES_WITH_VARIABLES)({ colors: base.cssVars, }) await fs.writeFile( path.join(REGISTRY_PATH, `colors/${baseColor}.json`), JSON.stringify(base, null, 2), 'utf8', ) // ---------------------------------------------------------------------------- // Build registry/themes.css // ---------------------------------------------------------------------------- const THEME_STYLES_WITH_VARIABLES = ` .theme-<%- theme %> { --background: <%- colors.light["background"] %>; --foreground: <%- colors.light["foreground"] %>; --muted: <%- colors.light["muted"] %>; --muted-foreground: <%- colors.light["muted-foreground"] %>; --popover: <%- colors.light["popover"] %>; --popover-foreground: <%- colors.light["popover-foreground"] %>; --card: <%- colors.light["card"] %>; --card-foreground: <%- colors.light["card-foreground"] %>; --border: <%- colors.light["border"] %>; --input: <%- colors.light["input"] %>; --primary: <%- colors.light["primary"] %>; --primary-foreground: <%- colors.light["primary-foreground"] %>; --secondary: <%- colors.light["secondary"] %>; --secondary-foreground: <%- colors.light["secondary-foreground"] %>; --accent: <%- colors.light["accent"] %>; --accent-foreground: <%- colors.light["accent-foreground"] %>; --destructive: <%- colors.light["destructive"] %>; --destructive-foreground: <%- colors.light["destructive-foreground"] %>; --ring: <%- colors.light["ring"] %>; --radius: <%- colors.light["radius"] %>; } .dark .theme-<%- theme %> { --background: <%- colors.dark["background"] %>; --foreground: <%- colors.dark["foreground"] %>; --muted: <%- colors.dark["muted"] %>; --muted-foreground: <%- colors.dark["muted-foreground"] %>; --popover: <%- colors.dark["popover"] %>; --popover-foreground: <%- colors.dark["popover-foreground"] %>; --card: <%- colors.dark["card"] %>; --card-foreground: <%- colors.dark["card-foreground"] %>; --border: <%- colors.dark["border"] %>; --input: <%- colors.dark["input"] %>; --primary: <%- colors.dark["primary"] %>; --primary-foreground: <%- colors.dark["primary-foreground"] %>; --secondary: <%- colors.dark["secondary"] %>; --secondary-foreground: <%- colors.dark["secondary-foreground"] %>; --accent: <%- colors.dark["accent"] %>; --accent-foreground: <%- colors.dark["accent-foreground"] %>; --destructive: <%- colors.dark["destructive"] %>; --destructive-foreground: <%- colors.dark["destructive-foreground"] %>; --ring: <%- colors.dark["ring"] %>; }` const themeCSS = [] for (const theme of baseColors) { themeCSS.push( template(THEME_STYLES_WITH_VARIABLES)({ colors: theme.cssVars, theme: theme.name, }), ) } await fs.writeFile( path.join(REGISTRY_PATH, `themes.css`), themeCSS.join('\n'), 'utf8', ) // ---------------------------------------------------------------------------- // Build registry/themes/[theme].json // ---------------------------------------------------------------------------- rimraf.sync(path.join(REGISTRY_PATH, 'themes')) for (const baseColor of ['slate', 'gray', 'zinc', 'neutral', 'stone']) { const payload: Record = { name: baseColor, label: baseColor.charAt(0).toUpperCase() + baseColor.slice(1), cssVars: {}, } for (const [mode, values] of Object.entries(colorMapping)) { payload.cssVars[mode] = {} for (const [key, value] of Object.entries(values)) { if (typeof value === 'string') { const resolvedColor = value.replace(/\{\{base\}\}-/g, `${baseColor}-`) payload.cssVars[mode][key] = resolvedColor const [resolvedBase, scale] = resolvedColor.split('-') const color = scale ? colorsData[resolvedBase].find( (item: any) => item.scale === Number.parseInt(scale), ) : colorsData[resolvedBase] if (color) { payload.cssVars[mode][key] = color.hslChannel } } } } const targetPath = path.join(REGISTRY_PATH, 'themes') // Create directory if it doesn't exist. if (!existsSync(targetPath)) { await fs.mkdir(targetPath, { recursive: true }) } await fs.writeFile( path.join(targetPath, `${payload.name}.json`), JSON.stringify(payload, null, 2), 'utf8', ) } } } // ---------------------------------------------------------------------------- // Build registry/icons/index.json. // ---------------------------------------------------------------------------- async function buildIcons() { const iconsTargetPath = path.join(REGISTRY_PATH, 'icons') rimraf.sync(iconsTargetPath) if (!existsSync(iconsTargetPath)) { await fs.mkdir(iconsTargetPath, { recursive: true }) } const iconsData = icons await fs.writeFile( path.join(iconsTargetPath, 'index.json'), JSON.stringify(iconsData, null, 2), 'utf8', ) } // ---------------------------------------------------------------------------- // Build __registry__/icons.ts. // ---------------------------------------------------------------------------- async function buildRegistryIcons() { 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 Icons = { ` for (const [icon, libraries] of Object.entries(icons)) { index += ` "${icon}": {` for (const [library, componentName] of Object.entries(libraries)) { const packageName = iconLibraries[library as keyof typeof iconLibraries].package if (packageName) { index += ` ${library}: React.lazy(() => import("${packageName}").then(mod => ({ default: mod.${componentName} }))),` } } index += ` },` } index += ` } ` // Write style index. rimraf.sync(path.join(process.cwd(), '__registry__/icons.ts')) await fs.writeFile( path.join(process.cwd(), '__registry__/icons.ts'), index, 'utf8', ) } try { const content = await crawlContent() const result = registrySchema.safeParse([...registry, ...content]) if (!result.success) { console.error(result.error) process.exit(1) } await buildRegistry(result.data) await buildStyles(result.data) await buildStylesIndex() await buildThemes() await buildRegistryIcons() await buildIcons() // eslint-disable-next-line no-console console.log('✅ Done!') } catch (error) { console.error(error) process.exit(1) }